From 57bcb2802d56ccbd66348d5ae740582301d66e65 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 1 Mar 2026 00:12:22 -0800 Subject: [PATCH] Improve auto-loop event emission and add ntfy notifications (#821) --- .gitignore | 2 +- apps/server/package.json | 2 +- .../src/services/auto-loop-coordinator.ts | 80 ++- .../server/src/services/event-hook-service.ts | 104 ++- .../src/services/feature-state-manager.ts | 272 ++++++-- apps/server/src/services/ntfy-service.ts | 282 ++++++++ apps/server/src/services/settings-service.ts | 32 + .../tests/unit/routes/running-agents.test.ts | 63 ++ .../services/auto-loop-coordinator.test.ts | 379 +++++++++++ .../auto-mode/facade-agent-runner.test.ts | 2 +- .../unit/services/event-hook-service.test.ts | 642 +++++++++++++++++- .../services/feature-state-manager.test.ts | 325 +++++++++ .../tests/unit/services/ntfy-service.test.ts | 642 ++++++++++++++++++ .../unit/services/settings-service.test.ts | 227 +++++++ apps/ui/package.json | 3 +- .../components/notification-bell.tsx | 28 +- apps/ui/src/components/session-manager.tsx | 8 +- apps/ui/src/components/views/board-view.tsx | 99 ++- .../kanban-card/agent-info-panel.tsx | 10 +- .../components/list-view/list-view.tsx | 8 +- .../board-view/dialogs/create-pr-dialog.tsx | 30 +- .../dialogs/create-worktree-dialog.tsx | 36 +- .../components/worktree-dropdown-item.tsx | 2 +- .../components/worktree-dropdown.tsx | 4 +- .../components/worktree-tab.tsx | 2 +- .../components/views/notifications-view.tsx | 29 +- .../project-bulk-replace-dialog.tsx | 1 + .../components/views/running-agents-view.tsx | 22 +- .../event-hooks/event-hook-dialog.tsx | 296 +++++++- .../event-hooks/event-hooks-section.tsx | 549 ++++++++++++++- .../model-defaults/bulk-replace-dialog.tsx | 1 + .../model-defaults/phase-model-selector.tsx | 2 +- apps/ui/src/hooks/queries/use-worktrees.ts | 2 +- .../src/hooks/use-agent-output-websocket.ts | 13 +- apps/ui/src/hooks/use-settings-migration.ts | 12 +- apps/ui/src/hooks/use-settings-sync.ts | 9 +- apps/ui/src/lib/electron.ts | 2 + apps/ui/src/lib/log-parser.ts | 2 +- apps/ui/src/lib/utils.ts | 31 + apps/ui/src/main.ts | 4 - apps/ui/src/routes/board.lazy.tsx | 9 +- apps/ui/src/routes/board.tsx | 10 +- apps/ui/src/store/app-store.ts | 12 + apps/ui/src/store/types/state-types.ts | 7 + apps/ui/src/types/electron.d.ts | 6 +- .../agent/start-new-chat-session.spec.ts | 9 +- .../tests/features/feature-deep-link.spec.ts | 176 +++++ .../settings/event-hooks-settings.spec.ts | 271 ++++++++ libs/types/src/index.ts | 5 + libs/types/src/notification.ts | 4 +- libs/types/src/settings.ts | 104 ++- package.json | 2 +- test/fixtures/projectA | 1 - 53 files changed, 4620 insertions(+), 255 deletions(-) create mode 100644 apps/server/src/services/ntfy-service.ts create mode 100644 apps/server/tests/unit/services/ntfy-service.test.ts create mode 100644 apps/ui/tests/features/feature-deep-link.spec.ts create mode 100644 apps/ui/tests/settings/event-hooks-settings.spec.ts delete mode 160000 test/fixtures/projectA diff --git a/.gitignore b/.gitignore index 2672e420..e9cb3275 100644 --- a/.gitignore +++ b/.gitignore @@ -71,7 +71,7 @@ test/agent-session-test-*/ test/feature-backlog-test-*/ test/running-task-display-test-*/ test/agent-output-modal-responsive-*/ -test/fixtures/.worker-*/ +test/fixtures/ test/board-bg-test-*/ test/edit-feature-test-*/ test/open-project-test-*/ diff --git a/apps/server/package.json b/apps/server/package.json index 75818b18..8fc0f5de 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@automaker/server", - "version": "0.15.0", + "version": "1.0.0", "description": "Backend server for Automaker - provides API for both web and Electron modes", "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", diff --git a/apps/server/src/services/auto-loop-coordinator.ts b/apps/server/src/services/auto-loop-coordinator.ts index 6d83e699..ef4a9155 100644 --- a/apps/server/src/services/auto-loop-coordinator.ts +++ b/apps/server/src/services/auto-loop-coordinator.ts @@ -15,6 +15,12 @@ const logger = createLogger('AutoLoopCoordinator'); const CONSECUTIVE_FAILURE_THRESHOLD = 3; const FAILURE_WINDOW_MS = 60000; +// Sleep intervals for the auto-loop (in milliseconds) +const SLEEP_INTERVAL_CAPACITY_MS = 5000; +const SLEEP_INTERVAL_IDLE_MS = 10000; +const SLEEP_INTERVAL_NORMAL_MS = 2000; +const SLEEP_INTERVAL_ERROR_MS = 5000; + export interface AutoModeConfig { maxConcurrency: number; useWorktrees: boolean; @@ -169,20 +175,32 @@ export class AutoLoopCoordinator { // presence is accounted for when deciding whether to dispatch new auto-mode tasks. const runningCount = await this.getRunningCountForWorktree(projectPath, branchName); if (runningCount >= projectState.config.maxConcurrency) { - await this.sleep(5000, projectState.abortController.signal); + await this.sleep(SLEEP_INTERVAL_CAPACITY_MS, projectState.abortController.signal); continue; } const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName); if (pendingFeatures.length === 0) { if (runningCount === 0 && !projectState.hasEmittedIdleEvent) { - this.eventBus.emitAutoModeEvent('auto_mode_idle', { - message: 'No pending features - auto mode idle', + // Double-check that we have no features in 'in_progress' state that might + // have been released from the concurrency manager but not yet updated to + // their final status. This prevents auto_mode_idle from firing prematurely + // when features are transitioning states (e.g., during status update). + const hasInProgressFeatures = await this.hasInProgressFeaturesForWorktree( projectPath, - branchName, - }); - projectState.hasEmittedIdleEvent = true; + branchName + ); + + // Only emit auto_mode_idle if we're truly done with all features + if (!hasInProgressFeatures) { + this.eventBus.emitAutoModeEvent('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath, + branchName, + }); + projectState.hasEmittedIdleEvent = true; + } } - await this.sleep(10000, projectState.abortController.signal); + await this.sleep(SLEEP_INTERVAL_IDLE_MS, projectState.abortController.signal); continue; } @@ -228,10 +246,10 @@ export class AutoLoopCoordinator { } }); } - await this.sleep(2000, projectState.abortController.signal); + await this.sleep(SLEEP_INTERVAL_NORMAL_MS, projectState.abortController.signal); } catch { if (projectState.abortController.signal.aborted) break; - await this.sleep(5000, projectState.abortController.signal); + await this.sleep(SLEEP_INTERVAL_ERROR_MS, projectState.abortController.signal); } } projectState.isRunning = false; @@ -462,4 +480,48 @@ export class AutoLoopCoordinator { signal?.addEventListener('abort', onAbort); }); } + + /** + * Check if a feature belongs to the current worktree based on branch name. + * For main worktree (branchName === null or 'main'): includes features with no branchName or branchName === 'main'. + * For feature worktrees (branchName !== null and !== 'main'): only includes features with matching branchName. + */ + private featureBelongsToWorktree(feature: Feature, branchName: string | null): boolean { + const isMainWorktree = branchName === null || branchName === 'main'; + if (isMainWorktree) { + // Main worktree: include features with no branchName or branchName === 'main' + return !feature.branchName || feature.branchName === 'main'; + } else { + // Feature worktree: only include exact branch match + return feature.branchName === branchName; + } + } + + /** + * Check if there are features in 'in_progress' status for the current worktree. + * This prevents auto_mode_idle from firing prematurely when features are + * transitioning states (e.g., during status update from in_progress to completed). + */ + private async hasInProgressFeaturesForWorktree( + projectPath: string, + branchName: string | null + ): Promise { + if (!this.loadAllFeaturesFn) { + return false; + } + + try { + const allFeatures = await this.loadAllFeaturesFn(projectPath); + return allFeatures.some( + (f) => f.status === 'in_progress' && this.featureBelongsToWorktree(f, branchName) + ); + } catch (error) { + const errorInfo = classifyError(error); + logger.warn( + `Failed to load all features for idle check (projectPath=${projectPath}, branchName=${branchName}): ${errorInfo.message}`, + error + ); + return false; + } + } } diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts index 376da964..64973565 100644 --- a/apps/server/src/services/event-hook-service.ts +++ b/apps/server/src/services/event-hook-service.ts @@ -27,7 +27,11 @@ import type { EventHookTrigger, EventHookShellAction, EventHookHttpAction, + EventHookNtfyAction, + NtfyEndpointConfig, + EventHookContext, } from '@automaker/types'; +import { ntfyService, type NtfyContext } from './ntfy-service.js'; const execAsync = promisify(exec); const logger = createLogger('EventHooks'); @@ -38,19 +42,8 @@ const DEFAULT_SHELL_TIMEOUT = 30000; /** Default timeout for HTTP requests (10 seconds) */ const DEFAULT_HTTP_TIMEOUT = 10000; -/** - * Context available for variable substitution in hooks - */ -interface HookContext { - featureId?: string; - featureName?: string; - projectPath?: string; - projectName?: string; - error?: string; - errorType?: string; - timestamp: string; - eventType: EventHookTrigger; -} +// Use the shared EventHookContext type (aliased locally as HookContext for clarity) +type HookContext = EventHookContext; /** * Auto-mode event payload structure @@ -451,6 +444,8 @@ export class EventHookService { await this.executeShellHook(hook.action, context, hookName); } else if (hook.action.type === 'http') { await this.executeHttpHook(hook.action, context, hookName); + } else if (hook.action.type === 'ntfy') { + await this.executeNtfyHook(hook.action, context, hookName); } } catch (error) { logger.error(`Hook "${hookName}" failed:`, error); @@ -558,6 +553,89 @@ export class EventHookService { } } + /** + * Execute an ntfy.sh notification hook + */ + private async executeNtfyHook( + action: EventHookNtfyAction, + context: HookContext, + hookName: string + ): Promise { + if (!this.settingsService) { + logger.warn('Settings service not available for ntfy hook'); + return; + } + + // Get the endpoint configuration + const settings = await this.settingsService.getGlobalSettings(); + const endpoints = settings.ntfyEndpoints || []; + const endpoint = endpoints.find((e) => e.id === action.endpointId); + + if (!endpoint) { + logger.error(`Ntfy hook "${hookName}" references unknown endpoint: ${action.endpointId}`); + return; + } + + // Convert HookContext to NtfyContext + const ntfyContext: NtfyContext = { + featureId: context.featureId, + featureName: context.featureName, + projectPath: context.projectPath, + projectName: context.projectName, + error: context.error, + errorType: context.errorType, + timestamp: context.timestamp, + eventType: context.eventType, + }; + + // Build click URL with deep-link if project context is available + let clickUrl = action.clickUrl; + if (!clickUrl && endpoint.defaultClickUrl) { + clickUrl = endpoint.defaultClickUrl; + // If we have a project path and the click URL looks like the server URL, + // append deep-link path + if (context.projectPath && clickUrl) { + try { + const url = new URL(clickUrl); + // Add featureId as query param for deep linking to board with feature output modal + if (context.featureId) { + url.pathname = '/board'; + url.searchParams.set('featureId', context.featureId); + } else if (context.projectPath) { + url.pathname = '/board'; + } + clickUrl = url.toString(); + } catch (error) { + // If URL parsing fails, log warning and use as-is + logger.warn( + `Failed to parse defaultClickUrl "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + + logger.info(`Executing ntfy hook "${hookName}" to endpoint "${endpoint.name}"`); + + const result = await ntfyService.sendNotification( + endpoint, + { + title: action.title, + body: action.body, + tags: action.tags, + emoji: action.emoji, + clickUrl, + priority: action.priority, + }, + ntfyContext + ); + + if (!result.success) { + logger.warn(`Ntfy hook "${hookName}" failed: ${result.error}`); + } else { + logger.info(`Ntfy hook "${hookName}" completed successfully`); + } + } + /** * Substitute {{variable}} placeholders in a string */ diff --git a/apps/server/src/services/feature-state-manager.ts b/apps/server/src/services/feature-state-manager.ts index aa53d08a..45004896 100644 --- a/apps/server/src/services/feature-state-manager.ts +++ b/apps/server/src/services/feature-state-manager.ts @@ -33,6 +33,36 @@ import { pipelineService } from './pipeline-service.js'; const logger = createLogger('FeatureStateManager'); +// Notification type constants +const NOTIFICATION_TYPE_WAITING_APPROVAL = 'feature_waiting_approval'; +const NOTIFICATION_TYPE_VERIFIED = 'feature_verified'; +const NOTIFICATION_TYPE_FEATURE_ERROR = 'feature_error'; +const NOTIFICATION_TYPE_AUTO_MODE_ERROR = 'auto_mode_error'; + +// Notification title constants +const NOTIFICATION_TITLE_WAITING_APPROVAL = 'Feature Ready for Review'; +const NOTIFICATION_TITLE_VERIFIED = 'Feature Verified'; +const NOTIFICATION_TITLE_FEATURE_ERROR = 'Feature Failed'; +const NOTIFICATION_TITLE_AUTO_MODE_ERROR = 'Auto Mode Error'; + +/** + * Auto-mode event payload structure + * This is the payload that comes with 'auto-mode:event' events + */ +interface AutoModeEventPayload { + type?: string; + featureId?: string; + featureName?: string; + passes?: boolean; + executionMode?: 'auto' | 'manual'; + message?: string; + error?: string; + errorType?: string; + projectPath?: string; + /** Status field present when type === 'feature_status_changed' */ + status?: string; +} + /** * FeatureStateManager handles feature status updates with persistence guarantees. * @@ -45,10 +75,28 @@ const logger = createLogger('FeatureStateManager'); export class FeatureStateManager { private events: EventEmitter; private featureLoader: FeatureLoader; + private unsubscribe: (() => void) | null = null; constructor(events: EventEmitter, featureLoader: FeatureLoader) { this.events = events; this.featureLoader = featureLoader; + + // Subscribe to error events to create notifications + this.unsubscribe = events.subscribe((type, payload) => { + if (type === 'auto-mode:event') { + this.handleAutoModeEventError(payload as AutoModeEventPayload); + } + }); + } + + /** + * Cleanup subscriptions + */ + destroy(): void { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } } /** @@ -106,77 +154,18 @@ export class FeatureStateManager { feature.status = status; feature.updatedAt = new Date().toISOString(); - // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) - // Badge will show for 2 minutes after this timestamp - if (status === 'waiting_approval') { + // Handle justFinishedAt timestamp based on status + const shouldSetJustFinishedAt = status === 'waiting_approval'; + const shouldClearJustFinishedAt = status !== 'waiting_approval'; + if (shouldSetJustFinishedAt) { feature.justFinishedAt = new Date().toISOString(); + } else if (shouldClearJustFinishedAt) { + feature.justFinishedAt = undefined; + } - // Finalize task statuses when feature is done: - // - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them) - // - Do NOT mark pending tasks as completed (they were never started) - // - Clear currentTaskId since no task is actively running - // This prevents cards in "waiting for review" from appearing to still have running tasks - if (feature.planSpec?.tasks) { - let tasksFinalized = 0; - let tasksPending = 0; - for (const task of feature.planSpec.tasks) { - if (task.status === 'in_progress') { - task.status = 'completed'; - tasksFinalized++; - } else if (task.status === 'pending') { - tasksPending++; - } - } - if (tasksFinalized > 0) { - logger.info( - `[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval` - ); - } - if (tasksPending > 0) { - logger.warn( - `[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total` - ); - } - // Update tasksCompleted count to reflect actual completed tasks - feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( - (t) => t.status === 'completed' - ).length; - feature.planSpec.currentTaskId = undefined; - } - } else if (status === 'verified') { - // Also finalize in_progress tasks when moving directly to verified (skipTests=false) - // Do NOT mark pending tasks as completed - they were never started - if (feature.planSpec?.tasks) { - let tasksFinalized = 0; - let tasksPending = 0; - for (const task of feature.planSpec.tasks) { - if (task.status === 'in_progress') { - task.status = 'completed'; - tasksFinalized++; - } else if (task.status === 'pending') { - tasksPending++; - } - } - if (tasksFinalized > 0) { - logger.info( - `[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified` - ); - } - if (tasksPending > 0) { - logger.warn( - `[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total` - ); - } - feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( - (t) => t.status === 'completed' - ).length; - feature.planSpec.currentTaskId = undefined; - } - // Clear the timestamp when moving to other statuses - feature.justFinishedAt = undefined; - } else { - // Clear the timestamp when moving to other statuses - feature.justFinishedAt = undefined; + // Finalize in-progress tasks when reaching terminal states (waiting_approval or verified) + if (status === 'waiting_approval' || status === 'verified') { + this.finalizeInProgressTasks(feature, featureId, status); } // PERSIST BEFORE EMIT (Pitfall 2) @@ -193,19 +182,21 @@ export class FeatureStateManager { // Wrapped in try-catch so failures don't block syncFeatureToAppSpec below try { const notificationService = getNotificationService(); + const displayName = this.getFeatureDisplayName(feature, featureId); + 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.`, + type: NOTIFICATION_TYPE_WAITING_APPROVAL, + title: displayName, + message: NOTIFICATION_TITLE_WAITING_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.`, + type: NOTIFICATION_TYPE_VERIFIED, + title: displayName, + message: NOTIFICATION_TITLE_VERIFIED, featureId, projectPath, }); @@ -736,6 +727,137 @@ export class FeatureStateManager { } } + /** + * Get the display name for a feature, preferring title over feature ID. + * Empty string titles are treated as missing and fallback to featureId. + * + * @param feature - The feature to get the display name for + * @param featureId - The feature ID to use as fallback + * @returns The display name (title or feature ID) + */ + private getFeatureDisplayName(feature: Feature, featureId: string): string { + // Use title if it's a non-empty string, otherwise fallback to featureId + return feature.title && feature.title.trim() ? feature.title : featureId; + } + + /** + * Handle auto-mode events to create error notifications. + * This listens for error events and creates notifications to alert users. + */ + private async handleAutoModeEventError(payload: AutoModeEventPayload): Promise { + if (!payload.type) return; + + // Only handle error events + if (payload.type !== 'auto_mode_error' && payload.type !== 'auto_mode_feature_complete') { + return; + } + + // For auto_mode_feature_complete, only notify on failures (passes === false) + if (payload.type === 'auto_mode_feature_complete' && payload.passes !== false) { + return; + } + + // Get project path - handle different event formats + const projectPath = payload.projectPath; + if (!projectPath) return; + + try { + const notificationService = getNotificationService(); + + // Determine notification type and title based on event type + // Only auto_mode_feature_complete events should create feature_error notifications + const isFeatureError = payload.type === 'auto_mode_feature_complete'; + const notificationType = isFeatureError + ? NOTIFICATION_TYPE_FEATURE_ERROR + : NOTIFICATION_TYPE_AUTO_MODE_ERROR; + const notificationTitle = isFeatureError + ? NOTIFICATION_TITLE_FEATURE_ERROR + : NOTIFICATION_TITLE_AUTO_MODE_ERROR; + + // Build error message + let errorMessage = payload.message || 'An error occurred'; + if (payload.error) { + errorMessage = payload.error; + } + + // Use feature title as notification title when available, fall back to gesture name + let title = notificationTitle; + if (payload.featureId) { + const displayName = await this.getFeatureDisplayNameById(projectPath, payload.featureId); + if (displayName) { + title = displayName; + errorMessage = `${notificationTitle}: ${errorMessage}`; + } + } + + await notificationService.createNotification({ + type: notificationType, + title, + message: errorMessage, + featureId: payload.featureId, + projectPath, + }); + } catch (notificationError) { + logger.warn(`Failed to create error notification:`, notificationError); + } + } + + /** + * Get feature display name by loading the feature directly. + */ + private async getFeatureDisplayNameById( + projectPath: string, + featureId: string + ): Promise { + const feature = await this.loadFeature(projectPath, featureId); + if (!feature) return null; + return this.getFeatureDisplayName(feature, featureId); + } + + /** + * Finalize in-progress tasks when a feature reaches a terminal state. + * Marks in_progress tasks as completed but leaves pending tasks untouched. + * + * @param feature - The feature whose tasks should be finalized + * @param featureId - The feature ID for logging + * @param targetStatus - The status the feature is transitioning to + */ + private finalizeInProgressTasks(feature: Feature, featureId: string, targetStatus: string): void { + if (!feature.planSpec?.tasks) { + return; + } + + let tasksFinalized = 0; + let tasksPending = 0; + + for (const task of feature.planSpec.tasks) { + if (task.status === 'in_progress') { + task.status = 'completed'; + tasksFinalized++; + } else if (task.status === 'pending') { + tasksPending++; + } + } + + // Update tasksCompleted count to reflect actual completed tasks + feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( + (t) => t.status === 'completed' + ).length; + feature.planSpec.currentTaskId = undefined; + + if (tasksFinalized > 0) { + logger.info( + `[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to ${targetStatus}` + ); + } + + if (tasksPending > 0) { + logger.warn( + `[updateFeatureStatus] Feature ${featureId} moving to ${targetStatus} with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total` + ); + } + } + /** * Emit an auto-mode event via the event emitter * diff --git a/apps/server/src/services/ntfy-service.ts b/apps/server/src/services/ntfy-service.ts new file mode 100644 index 00000000..c63ac6e2 --- /dev/null +++ b/apps/server/src/services/ntfy-service.ts @@ -0,0 +1,282 @@ +/** + * Ntfy Service - Sends push notifications via ntfy.sh + * + * Provides integration with ntfy.sh for push notifications. + * Supports custom servers, authentication, tags, emojis, and click actions. + * + * @see https://docs.ntfy.sh/publish/ + */ + +import { createLogger } from '@automaker/utils'; +import type { NtfyEndpointConfig, EventHookContext } from '@automaker/types'; + +const logger = createLogger('Ntfy'); + +/** Default timeout for ntfy HTTP requests (10 seconds) */ +const DEFAULT_NTFY_TIMEOUT = 10000; + +// Re-export EventHookContext as NtfyContext for backward compatibility +export type NtfyContext = EventHookContext; + +/** + * Ntfy Service + * + * Handles sending notifications to ntfy.sh endpoints. + */ +export class NtfyService { + /** + * Send a notification to a ntfy.sh endpoint + * + * @param endpoint The ntfy.sh endpoint configuration + * @param options Notification options (title, body, tags, etc.) + * @param context Context for variable substitution + */ + async sendNotification( + endpoint: NtfyEndpointConfig, + options: { + title?: string; + body?: string; + tags?: string; + emoji?: string; + clickUrl?: string; + priority?: 1 | 2 | 3 | 4 | 5; + }, + context: NtfyContext + ): Promise<{ success: boolean; error?: string }> { + if (!endpoint.enabled) { + logger.warn(`Ntfy endpoint "${endpoint.name}" is disabled, skipping notification`); + return { success: false, error: 'Endpoint is disabled' }; + } + + // Validate endpoint configuration + const validationError = this.validateEndpoint(endpoint); + if (validationError) { + logger.error(`Invalid ntfy endpoint configuration: ${validationError}`); + return { success: false, error: validationError }; + } + + // Build URL + const serverUrl = endpoint.serverUrl.replace(/\/$/, ''); // Remove trailing slash + const url = `${serverUrl}/${encodeURIComponent(endpoint.topic)}`; + + // Build headers + const headers: Record = { + 'Content-Type': 'text/plain; charset=utf-8', + }; + + // Title (with variable substitution) + const title = this.substituteVariables(options.title || this.getDefaultTitle(context), context); + if (title) { + headers['Title'] = title; + } + + // Priority + const priority = options.priority || 3; + headers['Priority'] = String(priority); + + // Tags and emoji + const tags = this.buildTags( + options.tags || endpoint.defaultTags, + options.emoji || endpoint.defaultEmoji + ); + if (tags) { + headers['Tags'] = tags; + } + + // Click action URL + const clickUrl = this.substituteVariables( + options.clickUrl || endpoint.defaultClickUrl || '', + context + ); + if (clickUrl) { + headers['Click'] = clickUrl; + } + + // Authentication + this.addAuthHeaders(headers, endpoint); + + // Message body (with variable substitution) + const body = this.substituteVariables(options.body || this.getDefaultBody(context), context); + + logger.info(`Sending ntfy notification to ${endpoint.name}: ${title}`); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), DEFAULT_NTFY_TIMEOUT); + + try { + const response = await fetch(url, { + method: 'POST', + headers, + body, + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + logger.error(`Ntfy notification failed with status ${response.status}: ${errorText}`); + return { + success: false, + error: `HTTP ${response.status}: ${errorText}`, + }; + } + + logger.info(`Ntfy notification sent successfully to ${endpoint.name}`); + return { success: true }; + } catch (error) { + if ((error as Error).name === 'AbortError') { + logger.error(`Ntfy notification timed out after ${DEFAULT_NTFY_TIMEOUT}ms`); + return { success: false, error: 'Request timed out' }; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Ntfy notification failed: ${errorMessage}`); + return { success: false, error: errorMessage }; + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Validate an ntfy endpoint configuration + */ + validateEndpoint(endpoint: NtfyEndpointConfig): string | null { + // Validate server URL + if (!endpoint.serverUrl) { + return 'Server URL is required'; + } + + try { + new URL(endpoint.serverUrl); + } catch { + return 'Invalid server URL format'; + } + + // Validate topic + if (!endpoint.topic) { + return 'Topic is required'; + } + + if (endpoint.topic.includes(' ') || endpoint.topic.includes('\t')) { + return 'Topic cannot contain spaces'; + } + + // Validate authentication + if (endpoint.authType === 'basic') { + if (!endpoint.username || !endpoint.password) { + return 'Username and password are required for basic authentication'; + } + } else if (endpoint.authType === 'token') { + if (!endpoint.token) { + return 'Access token is required for token authentication'; + } + } + + return null; + } + + /** + * Build tags string from tags and emoji + */ + private buildTags(tags?: string, emoji?: string): string { + const tagList: string[] = []; + + if (tags) { + // Split by comma and trim whitespace + const parsedTags = tags + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0); + tagList.push(...parsedTags); + } + + if (emoji) { + // Add emoji as first tag if it looks like a shortcode + if (emoji.startsWith(':') && emoji.endsWith(':')) { + tagList.unshift(emoji.slice(1, -1)); + } else if (!emoji.includes(' ')) { + // If it's a single emoji or shortcode without colons, add as-is + tagList.unshift(emoji); + } + } + + return tagList.join(','); + } + + /** + * Add authentication headers based on auth type + */ + private addAuthHeaders(headers: Record, endpoint: NtfyEndpointConfig): void { + if (endpoint.authType === 'basic' && endpoint.username && endpoint.password) { + const credentials = Buffer.from(`${endpoint.username}:${endpoint.password}`).toString( + 'base64' + ); + headers['Authorization'] = `Basic ${credentials}`; + } else if (endpoint.authType === 'token' && endpoint.token) { + headers['Authorization'] = `Bearer ${endpoint.token}`; + } + } + + /** + * Get default title based on event context + */ + private getDefaultTitle(context: NtfyContext): string { + const eventName = this.formatEventName(context.eventType); + if (context.featureName) { + return `${eventName}: ${context.featureName}`; + } + return eventName; + } + + /** + * Get default body based on event context + */ + private getDefaultBody(context: NtfyContext): string { + const lines: string[] = []; + + if (context.featureName) { + lines.push(`Feature: ${context.featureName}`); + } + if (context.featureId) { + lines.push(`ID: ${context.featureId}`); + } + if (context.projectName) { + lines.push(`Project: ${context.projectName}`); + } + if (context.error) { + lines.push(`Error: ${context.error}`); + } + lines.push(`Time: ${context.timestamp}`); + + return lines.join('\n'); + } + + /** + * Format event type to human-readable name + */ + private formatEventName(eventType: string): string { + const eventNames: Record = { + feature_created: 'Feature Created', + feature_success: 'Feature Completed', + feature_error: 'Feature Failed', + auto_mode_complete: 'Auto Mode Complete', + auto_mode_error: 'Auto Mode Error', + }; + return eventNames[eventType] || eventType; + } + + /** + * Substitute {{variable}} placeholders in a string + */ + private substituteVariables(template: string, context: NtfyContext): string { + return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => { + const value = context[variable as keyof NtfyContext]; + if (value === undefined || value === null) { + return ''; + } + return String(value); + }); + } +} + +// Singleton instance +export const ntfyService = new NtfyService(); diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 7b3ffa70..5986b877 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -618,6 +618,36 @@ export class SettingsService { ignoreEmptyArrayOverwrite('eventHooks'); } + // Guard ntfyEndpoints against accidental wipe + // (similar to eventHooks, these are user-configured and shouldn't be lost) + // Check for explicit permission to clear ntfyEndpoints (escape hatch for intentional clearing) + const allowEmptyNtfyEndpoints = + (sanitizedUpdates as Record).__allowEmptyNtfyEndpoints === true; + // Remove the flag so it doesn't get persisted + delete (sanitizedUpdates as Record).__allowEmptyNtfyEndpoints; + + if (!allowEmptyNtfyEndpoints) { + const currentNtfyLen = Array.isArray(current.ntfyEndpoints) + ? current.ntfyEndpoints.length + : 0; + const newNtfyLen = Array.isArray(sanitizedUpdates.ntfyEndpoints) + ? sanitizedUpdates.ntfyEndpoints.length + : currentNtfyLen; + + if (Array.isArray(sanitizedUpdates.ntfyEndpoints) && newNtfyLen === 0 && currentNtfyLen > 0) { + logger.warn( + '[WIPE_PROTECTION] Attempted to set ntfyEndpoints to empty array! Ignoring update.', + { + currentNtfyLen, + newNtfyLen, + } + ); + delete sanitizedUpdates.ntfyEndpoints; + } + } else { + logger.info('[INTENTIONAL_CLEAR] Clearing ntfyEndpoints via escape hatch'); + } + // Empty object overwrite guard const ignoreEmptyObjectOverwrite = (key: K): void => { const nextVal = sanitizedUpdates[key] as unknown; @@ -1023,6 +1053,8 @@ export class SettingsService { keyboardShortcuts: (appState.keyboardShortcuts as KeyboardShortcuts) || DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts, + eventHooks: (appState.eventHooks as GlobalSettings['eventHooks']) || [], + ntfyEndpoints: (appState.ntfyEndpoints as GlobalSettings['ntfyEndpoints']) || [], projects: (appState.projects as ProjectRef[]) || [], trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [], projectHistory: (appState.projectHistory as string[]) || [], diff --git a/apps/server/tests/unit/routes/running-agents.test.ts b/apps/server/tests/unit/routes/running-agents.test.ts index 59279668..dfd2e2ab 100644 --- a/apps/server/tests/unit/routes/running-agents.test.ts +++ b/apps/server/tests/unit/routes/running-agents.test.ts @@ -47,6 +47,8 @@ describe('running-agents routes', () => { projectPath: '/home/user/project', projectName: 'project', isAutoMode: true, + model: 'claude-sonnet-4-20250514', + provider: 'claude', title: 'Implement login feature', description: 'Add user authentication with OAuth', }, @@ -55,6 +57,8 @@ describe('running-agents routes', () => { projectPath: '/home/user/other-project', projectName: 'other-project', isAutoMode: false, + model: 'codex-gpt-5.1', + provider: 'codex', title: 'Fix navigation bug', description: undefined, }, @@ -82,6 +86,8 @@ describe('running-agents routes', () => { projectPath: '/project', projectName: 'project', isAutoMode: true, + model: undefined, + provider: undefined, title: undefined, description: undefined, }, @@ -141,6 +147,8 @@ describe('running-agents routes', () => { projectPath: `/project-${i}`, projectName: `project-${i}`, isAutoMode: i % 2 === 0, + model: i % 3 === 0 ? 'claude-sonnet-4-20250514' : 'claude-haiku-4-5', + provider: 'claude', title: `Feature ${i}`, description: `Description ${i}`, })); @@ -167,6 +175,8 @@ describe('running-agents routes', () => { projectPath: '/workspace/project-alpha', projectName: 'project-alpha', isAutoMode: true, + model: 'claude-sonnet-4-20250514', + provider: 'claude', title: 'Feature A', description: 'In project alpha', }, @@ -175,6 +185,8 @@ describe('running-agents routes', () => { projectPath: '/workspace/project-beta', projectName: 'project-beta', isAutoMode: false, + model: 'codex-gpt-5.1', + provider: 'codex', title: 'Feature B', description: 'In project beta', }, @@ -191,5 +203,56 @@ describe('running-agents routes', () => { expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha'); expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta'); }); + + it('should include model and provider information for running agents', async () => { + // Arrange + const runningAgents = [ + { + featureId: 'feature-claude', + projectPath: '/project', + projectName: 'project', + isAutoMode: true, + model: 'claude-sonnet-4-20250514', + provider: 'claude', + title: 'Claude Feature', + description: 'Using Claude model', + }, + { + featureId: 'feature-codex', + projectPath: '/project', + projectName: 'project', + isAutoMode: false, + model: 'codex-gpt-5.1', + provider: 'codex', + title: 'Codex Feature', + description: 'Using Codex model', + }, + { + featureId: 'feature-cursor', + projectPath: '/project', + projectName: 'project', + isAutoMode: false, + model: 'cursor-auto', + provider: 'cursor', + title: 'Cursor Feature', + description: 'Using Cursor model', + }, + ]; + + vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents); + + // Act + const handler = createIndexHandler(mockAutoModeService as AutoModeService); + await handler(req, res); + + // Assert + const response = vi.mocked(res.json).mock.calls[0][0]; + expect(response.runningAgents[0].model).toBe('claude-sonnet-4-20250514'); + expect(response.runningAgents[0].provider).toBe('claude'); + expect(response.runningAgents[1].model).toBe('codex-gpt-5.1'); + expect(response.runningAgents[1].provider).toBe('codex'); + expect(response.runningAgents[2].model).toBe('cursor-auto'); + expect(response.runningAgents[2].provider).toBe('cursor'); + }); }); }); diff --git a/apps/server/tests/unit/services/auto-loop-coordinator.test.ts b/apps/server/tests/unit/services/auto-loop-coordinator.test.ts index 92239997..7c39ea97 100644 --- a/apps/server/tests/unit/services/auto-loop-coordinator.test.ts +++ b/apps/server/tests/unit/services/auto-loop-coordinator.test.ts @@ -1050,4 +1050,383 @@ describe('auto-loop-coordinator.ts', () => { ); }); }); + + describe('auto_mode_idle emission timing (idle check fix)', () => { + it('emits auto_mode_idle when no features in any state (empty project)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration and idle event + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('does NOT emit auto_mode_idle when features are in in_progress status', async () => { + // No pending features (backlog/ready) + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + // But there are features in in_progress status + const inProgressFeature: Feature = { + ...testFeature, + id: 'feature-1', + status: 'in_progress', + title: 'In Progress Feature', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([inProgressFeature]); + // No running features in concurrency manager (they were released during status update) + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should NOT emit auto_mode_idle because there's an in_progress feature + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('emits auto_mode_idle after in_progress feature completes', async () => { + const completedFeature: Feature = { + ...testFeature, + id: 'feature-1', + status: 'completed', + title: 'Completed Feature', + }; + + // Initially has in_progress feature + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([completedFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should emit auto_mode_idle because all features are completed + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('does NOT emit auto_mode_idle for in_progress features in main worktree (no branchName)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + // Feature in main worktree has no branchName + const mainWorktreeFeature: Feature = { + ...testFeature, + id: 'feature-main', + status: 'in_progress', + title: 'Main Worktree Feature', + branchName: undefined, // Main worktree feature + }; + // Feature in branch worktree has branchName + const branchFeature: Feature = { + ...testFeature, + id: 'feature-branch', + status: 'in_progress', + title: 'Branch Feature', + branchName: 'feature/some-branch', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([mainWorktreeFeature, branchFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + // Start auto mode for main worktree + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should NOT emit auto_mode_idle because there's an in_progress feature in main worktree + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith( + 'auto_mode_idle', + expect.objectContaining({ + projectPath: '/test/project', + branchName: null, + }) + ); + }); + + it('does NOT emit auto_mode_idle for in_progress features with matching branchName', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + // Feature in matching branch + const matchingBranchFeature: Feature = { + ...testFeature, + id: 'feature-matching', + status: 'in_progress', + title: 'Matching Branch Feature', + branchName: 'feature/test-branch', + }; + // Feature in different branch + const differentBranchFeature: Feature = { + ...testFeature, + id: 'feature-different', + status: 'in_progress', + title: 'Different Branch Feature', + branchName: 'feature/other-branch', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([ + matchingBranchFeature, + differentBranchFeature, + ]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + // Start auto mode for feature/test-branch + await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch'); + + // Should NOT emit auto_mode_idle because there's an in_progress feature with matching branch + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith( + 'auto_mode_idle', + expect.objectContaining({ + projectPath: '/test/project', + branchName: 'feature/test-branch', + }) + ); + }); + + it('emits auto_mode_idle when in_progress feature has different branchName', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + // Only feature is in a different branch + const differentBranchFeature: Feature = { + ...testFeature, + id: 'feature-different', + status: 'in_progress', + title: 'Different Branch Feature', + branchName: 'feature/other-branch', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([differentBranchFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + // Start auto mode for feature/test-branch + await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch'); + + // Should emit auto_mode_idle because the in_progress feature is in a different branch + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: 'feature/test-branch', + }); + }); + + it('emits auto_mode_idle when only backlog/ready features exist and no running/in_progress features', async () => { + // backlog/ready features should be in loadPendingFeatures, not loadAllFeatures for idle check + // But this test verifies the idle check doesn't incorrectly block on backlog/ready + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No pending (for current iteration check) + const backlogFeature: Feature = { + ...testFeature, + id: 'feature-1', + status: 'backlog', + title: 'Backlog Feature', + }; + const readyFeature: Feature = { + ...testFeature, + id: 'feature-2', + status: 'ready', + title: 'Ready Feature', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([backlogFeature, readyFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should NOT emit auto_mode_idle because there are backlog/ready features + // (even though they're not in_progress, the idle check only looks at in_progress status) + // Actually, backlog/ready would be caught by loadPendingFeatures on next iteration, + // so this should emit idle since runningCount=0 and no in_progress features + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('handles loadAllFeaturesFn error gracefully (falls back to emitting idle)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockLoadAllFeatures).mockRejectedValue(new Error('Failed to load features')); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should still emit auto_mode_idle when loadAllFeatures fails (defensive behavior) + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('handles missing loadAllFeaturesFn gracefully (falls back to emitting idle)', async () => { + // Create coordinator without loadAllFeaturesFn + const coordWithoutLoadAll = new AutoLoopCoordinator( + mockEventBus, + mockConcurrencyManager, + mockSettingsService, + mockExecuteFeature, + mockLoadPendingFeatures, + mockSaveExecutionState, + mockClearExecutionState, + mockResetStuckFeatures, + mockIsFeatureFinished, + mockIsFeatureRunning + // loadAllFeaturesFn omitted + ); + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordWithoutLoadAll.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordWithoutLoadAll.stopAutoLoopForProject('/test/project', null); + + // Should emit auto_mode_idle when loadAllFeaturesFn is missing (defensive behavior) + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('only emits auto_mode_idle once per idle period (hasEmittedIdleEvent flag)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time multiple times to trigger multiple loop iterations + await vi.advanceTimersByTimeAsync(11000); // First idle check + await vi.advanceTimersByTimeAsync(11000); // Second idle check + await vi.advanceTimersByTimeAsync(11000); // Third idle check + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should only emit auto_mode_idle once despite multiple iterations + const idleCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_idle'); + expect(idleCalls.length).toBe(1); + }); + + it('premature auto_mode_idle bug scenario: runningCount=0 but feature still in_progress', async () => { + // This test reproduces the exact bug scenario described in the feature: + // When a feature completes, there's a brief window where: + // 1. The feature has been released from runningFeatures (so runningCount = 0) + // 2. The feature's status is still 'in_progress' during the status update transition + // 3. pendingFeatures returns empty (only checks 'backlog'/'ready' statuses) + // The fix ensures auto_mode_idle is NOT emitted in this window + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No backlog/ready features + // Feature is still in in_progress status (during status update transition) + const transitioningFeature: Feature = { + ...testFeature, + id: 'feature-1', + status: 'in_progress', + title: 'Transitioning Feature', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([transitioningFeature]); + // Feature has been released from concurrency manager (runningCount = 0) + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // The fix prevents auto_mode_idle from being emitted in this scenario + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + }); }); diff --git a/apps/server/tests/unit/services/auto-mode/facade-agent-runner.test.ts b/apps/server/tests/unit/services/auto-mode/facade-agent-runner.test.ts index f478a858..58cbaeb9 100644 --- a/apps/server/tests/unit/services/auto-mode/facade-agent-runner.test.ts +++ b/apps/server/tests/unit/services/auto-mode/facade-agent-runner.test.ts @@ -58,7 +58,7 @@ describe('AutoModeServiceFacade Agent Runner', () => { // Helper to access the private createRunAgentFn via factory creation facade = AutoModeServiceFacade.create('/project', { - events: { on: vi.fn(), emit: vi.fn() } as any, + events: { on: vi.fn(), emit: vi.fn(), subscribe: vi.fn().mockReturnValue(vi.fn()) } as any, settingsService: mockSettingsService, sharedServices: { eventBus: { emitAutoModeEvent: vi.fn() } as any, diff --git a/apps/server/tests/unit/services/event-hook-service.test.ts b/apps/server/tests/unit/services/event-hook-service.test.ts index ab06f9c1..900bb3b3 100644 --- a/apps/server/tests/unit/services/event-hook-service.test.ts +++ b/apps/server/tests/unit/services/event-hook-service.test.ts @@ -5,6 +5,9 @@ import type { SettingsService } from '../../../src/services/settings-service.js' import type { EventHistoryService } from '../../../src/services/event-history-service.js'; import type { FeatureLoader } from '../../../src/services/feature-loader.js'; +// Mock global fetch for ntfy tests +const originalFetch = global.fetch; + /** * Create a mock EventEmitter for testing */ @@ -38,9 +41,15 @@ function createMockEventEmitter(): EventEmitter & { /** * Create a mock SettingsService */ -function createMockSettingsService(hooks: unknown[] = []): SettingsService { +function createMockSettingsService( + hooks: unknown[] = [], + ntfyEndpoints: unknown[] = [] +): SettingsService { return { - getGlobalSettings: vi.fn().mockResolvedValue({ eventHooks: hooks }), + getGlobalSettings: vi.fn().mockResolvedValue({ + eventHooks: hooks, + ntfyEndpoints: ntfyEndpoints, + }), } as unknown as SettingsService; } @@ -70,6 +79,7 @@ describe('EventHookService', () => { let mockSettingsService: ReturnType; let mockEventHistoryService: ReturnType; let mockFeatureLoader: ReturnType; + let mockFetch: ReturnType; beforeEach(() => { service = new EventHookService(); @@ -77,10 +87,14 @@ describe('EventHookService', () => { mockSettingsService = createMockSettingsService(); mockEventHistoryService = createMockEventHistoryService(); mockFeatureLoader = createMockFeatureLoader(); + // Set up mock fetch for ntfy tests + mockFetch = vi.fn(); + global.fetch = mockFetch; }); afterEach(() => { service.destroy(); + global.fetch = originalFetch; }); describe('initialize', () => { @@ -832,4 +846,628 @@ describe('EventHookService', () => { expect(storeCall.error).toBe('Feature stopped by user'); }); }); + + describe('ntfy hook execution', () => { + const mockNtfyEndpoint = { + id: 'endpoint-1', + name: 'Test Endpoint', + serverUrl: 'https://ntfy.sh', + topic: 'test-topic', + authType: 'none' as const, + enabled: true, + }; + + it('should execute ntfy hook when endpoint is configured', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Success Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + title: 'Feature {{featureName}} completed!', + priority: 3, + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://ntfy.sh/test-topic'); + expect(options.method).toBe('POST'); + expect(options.headers['Title']).toBe('Feature Test Feature completed!'); + }); + + it('should NOT execute ntfy hook when endpoint is not found', async () => { + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Missing Endpoint', + action: { + type: 'ntfy', + endpointId: 'non-existent-endpoint', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + // Fetch should NOT have been called since endpoint doesn't exist + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should use ntfy endpoint default values when hook does not override', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaults = { + ...mockNtfyEndpoint, + defaultTags: 'default-tag', + defaultEmoji: 'tada', + defaultClickUrl: 'https://default.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_error', + name: 'Ntfy Error Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + // No title, tags, or emoji - should use endpoint defaults + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Failed Feature', + passes: false, + message: 'Something went wrong', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + // Should use default tags and emoji from endpoint + expect(options.headers['Tags']).toBe('tada,default-tag'); + // Click URL gets deep-link query param when feature context is available + expect(options.headers['Click']).toContain('https://default.example.com/board'); + expect(options.headers['Click']).toContain('featureId=feat-1'); + }); + + it('should send ntfy notification with authentication', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithAuth = { + ...mockNtfyEndpoint, + authType: 'token' as const, + token: 'tk_test_token', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Authenticated Ntfy Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithAuth]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Authorization']).toBe('Bearer tk_test_token'); + }); + + it('should handle ntfy notification failure gracefully', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook That Will Fail', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + // Should not throw - error should be caught gracefully + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + // Event should still be stored even if ntfy hook fails + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + it('should substitute variables in ntfy title and body', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Variables', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + title: '[{{projectName}}] {{featureName}}', + body: 'Feature {{featureId}} completed at {{timestamp}}', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-123', + featureName: 'Cool Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/my-project', + projectName: 'my-project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('[my-project] Cool Feature'); + expect(options.body).toContain('feat-123'); + }); + + it('should NOT execute ntfy hook when endpoint is disabled', async () => { + const disabledEndpoint = { + ...mockNtfyEndpoint, + enabled: false, + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Disabled Endpoint', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [disabledEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + // Fetch should not be called because endpoint is disabled + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should use hook-specific values over endpoint defaults', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaults = { + ...mockNtfyEndpoint, + defaultTags: 'default-tag', + defaultEmoji: 'default-emoji', + defaultClickUrl: 'https://default.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Overrides', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + tags: 'override-tag', + emoji: 'override-emoji', + clickUrl: 'https://override.example.com', + priority: 5, + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + // Hook values should override endpoint defaults + expect(options.headers['Tags']).toBe('override-emoji,override-tag'); + expect(options.headers['Click']).toBe('https://override.example.com'); + expect(options.headers['Priority']).toBe('5'); + }); + + describe('click URL deep linking', () => { + it('should generate board URL with featureId query param when feature context is available', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaultClickUrl = { + ...mockNtfyEndpoint, + defaultClickUrl: 'https://app.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'test-feature-123', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + const clickUrl = options.headers['Click']; + + // Should use /board path with featureId query param + expect(clickUrl).toContain('/board'); + expect(clickUrl).toContain('featureId=test-feature-123'); + // Should NOT use the old path-based format + expect(clickUrl).not.toContain('/feature/'); + }); + + it('should generate board URL without featureId when no feature context', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaultClickUrl = { + ...mockNtfyEndpoint, + defaultClickUrl: 'https://app.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'auto_mode_complete', + name: 'Auto Mode Complete Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + // Event without featureId but with projectPath (auto_mode_idle triggers auto_mode_complete) + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_idle', + executionMode: 'auto', + projectPath: '/test/project', + totalFeatures: 5, + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + const clickUrl = options.headers['Click']; + + // Should navigate to board without featureId + expect(clickUrl).toContain('/board'); + expect(clickUrl).not.toContain('featureId='); + }); + + it('should use hook-specific click URL overriding default with featureId', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaultClickUrl = { + ...mockNtfyEndpoint, + defaultClickUrl: 'https://default.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Custom Click URL', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + clickUrl: 'https://custom.example.com/custom-page', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-789', + featureName: 'Custom URL Test', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + const clickUrl = options.headers['Click']; + + // Should use the hook-specific click URL (not modified with featureId since it's a custom URL) + expect(clickUrl).toBe('https://custom.example.com/custom-page'); + }); + + it('should preserve existing query params when adding featureId', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaultClickUrl = { + ...mockNtfyEndpoint, + defaultClickUrl: 'https://app.example.com/board?view=list', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-456', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + const clickUrl = options.headers['Click']; + + // Should preserve existing query params and add featureId + expect(clickUrl).toContain('view=list'); + expect(clickUrl).toContain('featureId=feat-456'); + // Should be properly formatted URL + expect(clickUrl).toMatch(/^https:\/\/app\.example\.com\/board\?.+$/); + }); + }); + }); }); diff --git a/apps/server/tests/unit/services/feature-state-manager.test.ts b/apps/server/tests/unit/services/feature-state-manager.test.ts index cc00e1e3..d0c3ea4b 100644 --- a/apps/server/tests/unit/services/feature-state-manager.test.ts +++ b/apps/server/tests/unit/services/feature-state-manager.test.ts @@ -279,6 +279,81 @@ describe('FeatureStateManager', () => { ); }); + it('should use feature.title as notification title for waiting_approval status', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithTitle: Feature = { + ...mockFeature, + title: 'My Awesome Feature Title', + name: 'old-name-property', // name property exists but should not be used + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_waiting_approval', + title: 'My Awesome Feature Title', + message: 'Feature Ready for Review', + }) + ); + }); + + it('should fallback to featureId as notification title when feature.title is undefined in waiting_approval notification', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithoutTitle: Feature = { + ...mockFeature, + title: undefined, + name: 'old-name-property', + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithoutTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_waiting_approval', + title: 'feature-123', + message: 'Feature Ready for Review', + }) + ); + }); + + it('should handle empty string title by using featureId as notification title in waiting_approval notification', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithEmptyTitle: Feature = { + ...mockFeature, + title: '', + name: 'old-name-property', + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithEmptyTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_waiting_approval', + title: 'feature-123', + message: 'Feature Ready for Review', + }) + ); + }); + it('should create notification for verified status', async () => { const mockNotificationService = { createNotification: vi.fn() }; (getNotificationService as Mock).mockReturnValue(mockNotificationService); @@ -298,6 +373,81 @@ describe('FeatureStateManager', () => { ); }); + it('should use feature.title as notification title for verified status', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithTitle: Feature = { + ...mockFeature, + title: 'My Awesome Feature Title', + name: 'old-name-property', // name property exists but should not be used + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_verified', + title: 'My Awesome Feature Title', + message: 'Feature Verified', + }) + ); + }); + + it('should fallback to featureId as notification title when feature.title is undefined in verified notification', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithoutTitle: Feature = { + ...mockFeature, + title: undefined, + name: 'old-name-property', + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithoutTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_verified', + title: 'feature-123', + message: 'Feature Verified', + }) + ); + }); + + it('should handle empty string title by using featureId as notification title in verified notification', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithEmptyTitle: Feature = { + ...mockFeature, + title: '', + name: 'old-name-property', + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithEmptyTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_verified', + title: 'feature-123', + message: 'Feature Verified', + }) + ); + }); + it('should sync to app_spec for completed status', async () => { (readJsonWithRecovery as Mock).mockResolvedValue({ data: { ...mockFeature }, @@ -1211,4 +1361,179 @@ describe('FeatureStateManager', () => { expect(callOrder).toEqual(['persist', 'emit']); }); }); + + describe('handleAutoModeEventError', () => { + let subscribeCallback: (type: string, payload: unknown) => void; + + beforeEach(() => { + // Get the subscribe callback from the mock - the callback passed TO subscribe is at index [0] + // subscribe is called like: events.subscribe(callback), so callback is at mock.calls[0][0] + const mockCalls = (mockEvents.subscribe as Mock).mock.calls; + if (mockCalls.length > 0 && mockCalls[0].length > 0) { + subscribeCallback = mockCalls[0][0] as typeof subscribeCallback; + } + }); + + it('should ignore events with no type', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', {}); + + expect(mockNotificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should ignore non-error events', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_feature_complete', + passes: true, + projectPath: '/project', + }); + + expect(mockNotificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should create auto_mode_error notification with gesture name as title when no featureId', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_error', + message: 'Something went wrong', + projectPath: '/project', + }); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'auto_mode_error', + title: 'Auto Mode Error', + message: 'Something went wrong', + projectPath: '/project', + }) + ); + }); + + it('should use error field instead of message when available', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_error', + message: 'Some message', + error: 'The actual error', + projectPath: '/project', + }); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'auto_mode_error', + message: 'The actual error', + }) + ); + }); + + it('should use feature title as notification title for feature error with featureId', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, title: 'Login Page Feature' }, + recovered: false, + source: 'main', + }); + + subscribeCallback('auto-mode:event', { + type: 'auto_mode_feature_complete', + passes: false, + featureId: 'feature-123', + error: 'Build failed', + projectPath: '/project', + }); + + // Wait for async handleAutoModeEventError to complete + await vi.waitFor(() => { + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_error', + title: 'Login Page Feature', + message: 'Feature Failed: Build failed', + featureId: 'feature-123', + }) + ); + }); + }); + + it('should ignore auto_mode_feature_complete without passes=false', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_feature_complete', + passes: true, + projectPath: '/project', + }); + + expect(mockNotificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should handle missing projectPath gracefully', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_error', + message: 'Error occurred', + }); + + expect(mockNotificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should handle notification service failures gracefully', async () => { + (getNotificationService as Mock).mockImplementation(() => { + throw new Error('Service unavailable'); + }); + + // Should not throw - the callback returns void so we just call it and wait for async work + subscribeCallback('auto-mode:event', { + type: 'auto_mode_error', + message: 'Error', + projectPath: '/project', + }); + + // Give async handleAutoModeEventError time to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }); + + describe('destroy', () => { + it('should unsubscribe from event subscription', () => { + const unsubscribeFn = vi.fn(); + (mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn); + + // Create a new manager to get a fresh subscription + const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader); + + // Call destroy + newManager.destroy(); + + // Verify unsubscribe was called + expect(unsubscribeFn).toHaveBeenCalled(); + }); + + it('should handle destroy being called multiple times', () => { + const unsubscribeFn = vi.fn(); + (mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn); + + const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader); + + // Call destroy multiple times + newManager.destroy(); + newManager.destroy(); + + // Should only unsubscribe once + expect(unsubscribeFn).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/apps/server/tests/unit/services/ntfy-service.test.ts b/apps/server/tests/unit/services/ntfy-service.test.ts new file mode 100644 index 00000000..0a2cc195 --- /dev/null +++ b/apps/server/tests/unit/services/ntfy-service.test.ts @@ -0,0 +1,642 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NtfyService } from '../../../src/services/ntfy-service.js'; +import type { NtfyEndpointConfig } from '@automaker/types'; + +// Mock global fetch +const originalFetch = global.fetch; + +describe('NtfyService', () => { + let service: NtfyService; + let mockFetch: ReturnType; + + beforeEach(() => { + service = new NtfyService(); + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + /** + * Create a valid endpoint config for testing + */ + function createEndpoint(overrides: Partial = {}): NtfyEndpointConfig { + return { + id: 'test-endpoint-id', + name: 'Test Endpoint', + serverUrl: 'https://ntfy.sh', + topic: 'test-topic', + authType: 'none', + enabled: true, + ...overrides, + }; + } + + /** + * Create a basic context for testing + */ + function createContext() { + return { + featureId: 'feat-123', + featureName: 'Test Feature', + projectPath: '/test/project', + projectName: 'test-project', + timestamp: '2024-01-15T10:30:00.000Z', + eventType: 'feature_success', + }; + } + + describe('validateEndpoint', () => { + it('should return null for valid endpoint with no auth', () => { + const endpoint = createEndpoint(); + const result = service.validateEndpoint(endpoint); + expect(result).toBeNull(); + }); + + it('should return null for valid endpoint with basic auth', () => { + const endpoint = createEndpoint({ + authType: 'basic', + username: 'user', + password: 'pass', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBeNull(); + }); + + it('should return null for valid endpoint with token auth', () => { + const endpoint = createEndpoint({ + authType: 'token', + token: 'tk_123456', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBeNull(); + }); + + it('should return error when serverUrl is missing', () => { + const endpoint = createEndpoint({ serverUrl: '' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Server URL is required'); + }); + + it('should return error when serverUrl is invalid', () => { + const endpoint = createEndpoint({ serverUrl: 'not-a-valid-url' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Invalid server URL format'); + }); + + it('should return error when topic is missing', () => { + const endpoint = createEndpoint({ topic: '' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Topic is required'); + }); + + it('should return error when topic contains spaces', () => { + const endpoint = createEndpoint({ topic: 'invalid topic' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Topic cannot contain spaces'); + }); + + it('should return error when topic contains tabs', () => { + const endpoint = createEndpoint({ topic: 'invalid\ttopic' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Topic cannot contain spaces'); + }); + + it('should return error when basic auth is missing username', () => { + const endpoint = createEndpoint({ + authType: 'basic', + username: '', + password: 'pass', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Username and password are required for basic authentication'); + }); + + it('should return error when basic auth is missing password', () => { + const endpoint = createEndpoint({ + authType: 'basic', + username: 'user', + password: '', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Username and password are required for basic authentication'); + }); + + it('should return error when token auth is missing token', () => { + const endpoint = createEndpoint({ + authType: 'token', + token: '', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Access token is required for token authentication'); + }); + }); + + describe('sendNotification', () => { + it('should return error when endpoint is disabled', async () => { + const endpoint = createEndpoint({ enabled: false }); + const result = await service.sendNotification(endpoint, {}, createContext()); + expect(result.success).toBe(false); + expect(result.error).toBe('Endpoint is disabled'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should return error when endpoint validation fails', async () => { + const endpoint = createEndpoint({ serverUrl: '' }); + const result = await service.sendNotification(endpoint, {}, createContext()); + expect(result.success).toBe(false); + expect(result.error).toBe('Server URL is required'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should send notification with default values', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://ntfy.sh/test-topic'); + expect(options.method).toBe('POST'); + expect(options.headers['Content-Type']).toBe('text/plain; charset=utf-8'); + expect(options.headers['Title']).toContain('Feature Completed'); + expect(options.headers['Priority']).toBe('3'); + }); + + it('should send notification with custom title and body', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification( + endpoint, + { + title: 'Custom Title', + body: 'Custom body message', + }, + createContext() + ); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Custom Title'); + expect(options.body).toBe('Custom body message'); + }); + + it('should send notification with tags and emoji', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification( + endpoint, + { + tags: 'warning,skull', + emoji: 'tada', + }, + createContext() + ); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('tada,warning,skull'); + }); + + it('should send notification with priority', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, { priority: 5 }, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Priority']).toBe('5'); + }); + + it('should send notification with click URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification( + endpoint, + { clickUrl: 'https://example.com/feature/123' }, + createContext() + ); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Click']).toBe('https://example.com/feature/123'); + }); + + it('should use endpoint default tags and emoji when not specified', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ + defaultTags: 'default-tag', + defaultEmoji: 'rocket', + }); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('rocket,default-tag'); + }); + + it('should use endpoint default click URL when not specified', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ + defaultClickUrl: 'https://default.example.com', + }); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Click']).toBe('https://default.example.com'); + }); + + it('should send notification with basic authentication', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ + authType: 'basic', + username: 'testuser', + password: 'testpass', + }); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + // Basic auth should be base64 encoded + const expectedAuth = Buffer.from('testuser:testpass').toString('base64'); + expect(options.headers['Authorization']).toBe(`Basic ${expectedAuth}`); + }); + + it('should send notification with token authentication', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ + authType: 'token', + token: 'tk_test_token_123', + }); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Authorization']).toBe('Bearer tk_test_token_123'); + }); + + it('should return error on HTTP error response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: () => Promise.resolve('Forbidden - invalid token'), + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(false); + expect(result.error).toContain('403'); + expect(result.error).toContain('Forbidden'); + }); + + it('should return error on timeout', async () => { + mockFetch.mockImplementationOnce(() => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + throw error; + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(false); + expect(result.error).toBe('Request timed out'); + }); + + it('should return error on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + + it('should handle server URL with trailing slash', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ serverUrl: 'https://ntfy.sh/' }); + await service.sendNotification(endpoint, {}, createContext()); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toBe('https://ntfy.sh/test-topic'); + }); + + it('should URL encode the topic', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ topic: 'test/topic#special' }); + await service.sendNotification(endpoint, {}, createContext()); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('test%2Ftopic%23special'); + }); + }); + + describe('variable substitution', () => { + it('should substitute {{featureId}} in title', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { title: 'Feature {{featureId}} completed' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature feat-123 completed'); + }); + + it('should substitute {{featureName}} in body', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { body: 'The feature "{{featureName}}" is done!' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.body).toBe('The feature "Test Feature" is done!'); + }); + + it('should substitute {{projectName}} in title', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { title: '[{{projectName}}] Event: {{eventType}}' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('[test-project] Event: feature_success'); + }); + + it('should substitute {{timestamp}} in body', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { body: 'Completed at: {{timestamp}}' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.body).toBe('Completed at: 2024-01-15T10:30:00.000Z'); + }); + + it('should substitute {{error}} in body for error events', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { + ...createContext(), + eventType: 'feature_error', + error: 'Something went wrong', + }; + await service.sendNotification(endpoint, { title: 'Error: {{error}}' }, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Error: Something went wrong'); + }); + + it('should substitute multiple variables', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { + title: '[{{projectName}}] {{featureName}}', + body: 'Feature {{featureId}} completed at {{timestamp}}', + }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('[test-project] Test Feature'); + expect(options.body).toBe('Feature feat-123 completed at 2024-01-15T10:30:00.000Z'); + }); + + it('should replace unknown variables with empty string', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { title: 'Value: {{unknownVariable}}' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Value: '); + }); + }); + + describe('default title generation', () => { + it('should generate title with feature name for feature_success', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification(endpoint, {}, createContext()); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature Completed: Test Feature'); + }); + + it('should generate title without feature name when missing', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { ...createContext(), featureName: undefined }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature Completed'); + }); + + it('should generate correct title for feature_created', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { ...createContext(), eventType: 'feature_created' }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature Created: Test Feature'); + }); + + it('should generate correct title for feature_error', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { ...createContext(), eventType: 'feature_error' }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature Failed: Test Feature'); + }); + + it('should generate correct title for auto_mode_complete', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { + ...createContext(), + eventType: 'auto_mode_complete', + featureName: undefined, + }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Auto Mode Complete'); + }); + + it('should generate correct title for auto_mode_error', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { ...createContext(), eventType: 'auto_mode_error', featureName: undefined }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Auto Mode Error'); + }); + }); + + describe('default body generation', () => { + it('should generate body with feature info', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification(endpoint, {}, createContext()); + + const options = mockFetch.mock.calls[0][1]; + expect(options.body).toContain('Feature: Test Feature'); + expect(options.body).toContain('ID: feat-123'); + expect(options.body).toContain('Project: test-project'); + expect(options.body).toContain('Time: 2024-01-15T10:30:00.000Z'); + }); + + it('should include error in body for error events', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { + ...createContext(), + eventType: 'feature_error', + error: 'Build failed', + }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.body).toContain('Error: Build failed'); + }); + }); + + describe('emoji and tags handling', () => { + it('should handle emoji shortcode with colons', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification(endpoint, { emoji: ':tada:' }, createContext()); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('tada'); + }); + + it('should handle emoji without colons', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification(endpoint, { emoji: 'warning' }, createContext()); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('warning'); + }); + + it('should combine emoji and tags correctly', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { emoji: 'rotating_light', tags: 'urgent,alert' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + // Emoji comes first, then tags + expect(options.headers['Tags']).toBe('rotating_light,urgent,alert'); + }); + + it('should ignore emoji with spaces', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { emoji: 'multi word emoji', tags: 'test' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('test'); + }); + }); +}); diff --git a/apps/server/tests/unit/services/settings-service.test.ts b/apps/server/tests/unit/services/settings-service.test.ts index e54358fc..4188cd9d 100644 --- a/apps/server/tests/unit/services/settings-service.test.ts +++ b/apps/server/tests/unit/services/settings-service.test.ts @@ -14,12 +14,28 @@ import { type Credentials, type ProjectSettings, } from '@/types/settings.js'; +import type { NtfyEndpointConfig } from '@automaker/types'; describe('settings-service.ts', () => { let testDataDir: string; let testProjectDir: string; let settingsService: SettingsService; + /** + * Helper to create a test ntfy endpoint with sensible defaults + */ + function createTestNtfyEndpoint(overrides: Partial = {}): NtfyEndpointConfig { + return { + id: `endpoint-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + name: 'Test Endpoint', + serverUrl: 'https://ntfy.sh', + topic: 'test-topic', + authType: 'none', + enabled: true, + ...overrides, + }; + } + beforeEach(async () => { testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`); testProjectDir = path.join(os.tmpdir(), `project-test-${Date.now()}`); @@ -171,6 +187,150 @@ describe('settings-service.ts', () => { expect(updated.theme).toBe('solarized'); }); + it('should not overwrite non-empty ntfyEndpoints with an empty array (data loss guard)', async () => { + const endpoint1 = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'My Ntfy', + topic: 'my-topic', + }); + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ntfyEndpoints: [endpoint1] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [], + } as any); + + // The empty array should be ignored - existing endpoints should be preserved + expect(updated.ntfyEndpoints?.length).toBe(1); + expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + }); + + it('should allow adding new ntfyEndpoints to existing list', async () => { + const endpoint1 = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'First Endpoint', + topic: 'first-topic', + }); + const endpoint2 = createTestNtfyEndpoint({ + id: 'endpoint-2', + name: 'Second Endpoint', + serverUrl: 'https://ntfy.example.com', + topic: 'second-topic', + authType: 'token', + token: 'test-token', + }); + + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ntfyEndpoints: [endpoint1] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [endpoint1, endpoint2] as any, + }); + + // Both endpoints should be present + expect(updated.ntfyEndpoints?.length).toBe(2); + expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + expect((updated.ntfyEndpoints as any)?.[1]?.id).toBe('endpoint-2'); + }); + + it('should allow updating ntfyEndpoints with non-empty array', async () => { + const originalEndpoint = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'Original Name', + topic: 'original-topic', + }); + const updatedEndpoint = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'Updated Name', + topic: 'updated-topic', + enabled: false, + }); + + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ntfyEndpoints: [originalEndpoint] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [updatedEndpoint] as any, + }); + + // The update should go through with the new values + expect(updated.ntfyEndpoints?.length).toBe(1); + expect((updated.ntfyEndpoints as any)?.[0]?.name).toBe('Updated Name'); + expect((updated.ntfyEndpoints as any)?.[0]?.topic).toBe('updated-topic'); + expect((updated.ntfyEndpoints as any)?.[0]?.enabled).toBe(false); + }); + + it('should allow empty ntfyEndpoints when no existing endpoints exist', async () => { + // Start with no endpoints (default state) + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(DEFAULT_GLOBAL_SETTINGS, null, 2)); + + // Trying to set empty array should be fine when there are no existing endpoints + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [], + } as any); + + // Empty array should be set (no data loss because there was nothing to lose) + expect(updated.ntfyEndpoints?.length ?? 0).toBe(0); + }); + + it('should preserve ntfyEndpoints while updating other settings', async () => { + const endpoint = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'My Endpoint', + topic: 'my-topic', + }); + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + theme: 'dark', + ntfyEndpoints: [endpoint] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + // Update theme without sending ntfyEndpoints + const updated = await settingsService.updateGlobalSettings({ + theme: 'light', + }); + + // Theme should be updated + expect(updated.theme).toBe('light'); + // ntfyEndpoints should be preserved from existing settings + expect(updated.ntfyEndpoints?.length).toBe(1); + expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + }); + + it('should allow clearing ntfyEndpoints with escape hatch flag', async () => { + const endpoint = createTestNtfyEndpoint({ id: 'endpoint-1' }); + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ntfyEndpoints: [endpoint] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + // Use escape hatch to intentionally clear ntfyEndpoints + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [], + __allowEmptyNtfyEndpoints: true, + } as any); + + // The empty array should be applied because escape hatch was used + expect(updated.ntfyEndpoints?.length ?? 0).toBe(0); + }); + it('should create data directory if it does not exist', async () => { const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`); const newService = new SettingsService(newDataDir); @@ -562,6 +722,73 @@ describe('settings-service.ts', () => { expect(projectSettings.boardBackground?.imagePath).toBe('/path/to/image.jpg'); }); + it('should migrate ntfyEndpoints from localStorage data', async () => { + const localStorageData = { + 'automaker-storage': JSON.stringify({ + state: { + ntfyEndpoints: [ + { + id: 'endpoint-1', + name: 'My Ntfy Server', + serverUrl: 'https://ntfy.sh', + topic: 'my-topic', + authType: 'none', + enabled: true, + }, + ], + }, + }), + }; + + const result = await settingsService.migrateFromLocalStorage(localStorageData); + + expect(result.success).toBe(true); + expect(result.migratedGlobalSettings).toBe(true); + + const settings = await settingsService.getGlobalSettings(); + expect(settings.ntfyEndpoints?.length).toBe(1); + expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + expect((settings.ntfyEndpoints as any)?.[0]?.name).toBe('My Ntfy Server'); + expect((settings.ntfyEndpoints as any)?.[0]?.topic).toBe('my-topic'); + }); + + it('should migrate eventHooks and ntfyEndpoints together from localStorage data', async () => { + const localStorageData = { + 'automaker-storage': JSON.stringify({ + state: { + eventHooks: [ + { + id: 'hook-1', + name: 'Test Hook', + eventType: 'feature:started', + enabled: true, + actions: [], + }, + ], + ntfyEndpoints: [ + { + id: 'endpoint-1', + name: 'My Endpoint', + serverUrl: 'https://ntfy.sh', + topic: 'test-topic', + authType: 'none', + enabled: true, + }, + ], + }, + }), + }; + + const result = await settingsService.migrateFromLocalStorage(localStorageData); + + expect(result.success).toBe(true); + const settings = await settingsService.getGlobalSettings(); + expect(settings.eventHooks?.length).toBe(1); + expect(settings.ntfyEndpoints?.length).toBe(1); + expect((settings.eventHooks as any)?.[0]?.id).toBe('hook-1'); + expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + }); + it('should handle direct localStorage values', async () => { const localStorageData = { 'automaker:lastProjectDir': '/path/to/project', diff --git a/apps/ui/package.json b/apps/ui/package.json index ff1cfb1c..a95bb3ec 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -1,6 +1,6 @@ { "name": "@automaker/ui", - "version": "0.15.0", + "version": "1.0.0", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", "homepage": "https://github.com/AutoMaker-Org/automaker", "repository": { @@ -9,6 +9,7 @@ }, "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "desktopName": "automaker.desktop", "private": true, "engines": { "node": ">=22.0.0 <23.0.0" diff --git a/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx b/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx index 8217865d..8145f971 100644 --- a/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx @@ -3,7 +3,7 @@ */ import { useCallback } from 'react'; -import { Bell, Check, Trash2 } from 'lucide-react'; +import { Bell, Check, Trash2, AlertCircle } from 'lucide-react'; import { useNavigate } from '@tanstack/react-router'; import { useNotificationsStore } from '@/store/notifications-store'; import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events'; @@ -11,25 +11,7 @@ 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(); -} +import { cn, formatRelativeTime } from '@/lib/utils'; interface NotificationBellProps { projectPath: string | null; @@ -86,7 +68,7 @@ export function NotificationBell({ projectPath }: NotificationBellProps) { // Navigate to the relevant view based on notification type if (notification.featureId) { - navigate({ to: '/board' }); + navigate({ to: '/board', search: { featureId: notification.featureId } }); } }, [handleMarkAsRead, setPopoverOpen, navigate] @@ -105,6 +87,10 @@ export function NotificationBell({ projectPath }: NotificationBellProps) { return ; case 'spec_regeneration_complete': return ; + case 'feature_error': + return ; + case 'auto_mode_error': + return ; default: return ; } diff --git a/apps/ui/src/components/session-manager.tsx b/apps/ui/src/components/session-manager.tsx index 49958c95..b7a1dec0 100644 --- a/apps/ui/src/components/session-manager.tsx +++ b/apps/ui/src/components/session-manager.tsx @@ -195,8 +195,10 @@ export function SessionManager({ if (result.success && result.session?.id) { setNewSessionName(''); setIsCreating(false); - await invalidateSessions(); + // Select the new session immediately before invalidating the cache to avoid + // a race condition where the cache re-render resets the selected session. onSelectSession(result.session.id); + await invalidateSessions(); } }; @@ -210,8 +212,10 @@ export function SessionManager({ const result = await api.sessions.create(sessionName, projectPath, effectiveWorkingDirectory); if (result.success && result.session?.id) { - await invalidateSessions(); + // Select the new session immediately before invalidating the cache to avoid + // a race condition where the cache re-render resets the selected session. onSelectSession(result.session.id); + await invalidateSessions(); } }, [effectiveWorkingDirectory, projectPath, invalidateSessions, onSelectSession]); diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 90dda4f4..f902b1bb 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -114,7 +114,12 @@ const EMPTY_WORKTREES: ReturnType['getWo const logger = createLogger('Board'); -export function BoardView() { +interface BoardViewProps { + /** Feature ID from URL parameter - if provided, opens output modal for this feature on load */ + initialFeatureId?: string; +} + +export function BoardView({ initialFeatureId }: BoardViewProps) { const { currentProject, defaultSkipTests, @@ -300,6 +305,93 @@ export function BoardView() { setFeaturesWithContext, }); + // Handle initial feature ID from URL - switch to the correct worktree and open output modal + // Uses a ref to track which featureId has been handled to prevent re-opening + // when the component re-renders but initialFeatureId hasn't changed. + // We read worktrees from the store reactively so this effect re-runs once worktrees load. + const handledFeatureIdRef = useRef(undefined); + + // Reset the handled ref whenever initialFeatureId changes (including to undefined), + // so navigating to the same featureId again after clearing works correctly. + useEffect(() => { + handledFeatureIdRef.current = undefined; + }, [initialFeatureId]); + const deepLinkWorktrees = useAppStore( + useCallback( + (s) => + currentProject?.path + ? (s.worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES) + : EMPTY_WORKTREES, + [currentProject?.path] + ) + ); + useEffect(() => { + if ( + !initialFeatureId || + handledFeatureIdRef.current === initialFeatureId || + isLoading || + !hookFeatures.length || + !currentProject?.path + ) { + return; + } + + const feature = hookFeatures.find((f) => f.id === initialFeatureId); + if (!feature) return; + + // If the feature has a branch, wait for worktrees to load so we can switch + if (feature.branchName && deepLinkWorktrees.length === 0) { + return; // Worktrees not loaded yet - effect will re-run when they load + } + + // Switch to the correct worktree based on the feature's branchName + if (feature.branchName && deepLinkWorktrees.length > 0) { + const targetWorktree = deepLinkWorktrees.find((w) => w.branch === feature.branchName); + if (targetWorktree) { + const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path); + const isAlreadySelected = targetWorktree.isMain + ? currentWt?.path === null + : currentWt?.path === targetWorktree.path; + if (!isAlreadySelected) { + logger.info( + `Deep link: switching to worktree "${targetWorktree.branch}" for feature ${initialFeatureId}` + ); + setCurrentWorktree( + currentProject.path, + targetWorktree.isMain ? null : targetWorktree.path, + targetWorktree.branch + ); + } + } + } else if (!feature.branchName && deepLinkWorktrees.length > 0) { + // Feature has no branch - should be on the main worktree + const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path); + if (currentWt?.path !== null && currentWt !== null) { + const mainWorktree = deepLinkWorktrees.find((w) => w.isMain); + if (mainWorktree) { + logger.info( + `Deep link: switching to main worktree for unassigned feature ${initialFeatureId}` + ); + setCurrentWorktree(currentProject.path, null, mainWorktree.branch); + } + } + } + + logger.info(`Opening output modal for feature from URL: ${initialFeatureId}`); + setOutputFeature(feature); + setShowOutputModal(true); + handledFeatureIdRef.current = initialFeatureId; + }, [ + initialFeatureId, + isLoading, + hookFeatures, + currentProject?.path, + deepLinkWorktrees, + setCurrentWorktree, + setOutputFeature, + setShowOutputModal, + ]); + // Load pipeline config when project changes useEffect(() => { if (!currentProject?.path) return; @@ -1988,7 +2080,10 @@ export function BoardView() { {/* Agent Output Modal */} setShowOutputModal(false)} + onClose={() => { + setShowOutputModal(false); + handledFeatureIdRef.current = undefined; + }} featureDescription={outputFeature?.description || ''} featureId={outputFeature?.id || ''} featureStatus={outputFeature?.status} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index e27c52ab..fa6f3b0c 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -85,7 +85,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ Map >(new Map()); // Track real-time task summary updates from WebSocket events - const [taskSummaryMap, setTaskSummaryMap] = useState>(new Map()); + const [taskSummaryMap, setTaskSummaryMap] = useState>(new Map()); // Track last WebSocket event timestamp to know if we're receiving real-time updates const [lastWsEventTimestamp, setLastWsEventTimestamp] = useState(null); @@ -200,7 +200,11 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ // Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos // Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates const isFeatureFinished = feature.status === 'waiting_approval' || feature.status === 'verified'; - const effectiveTodos = useMemo(() => { + const effectiveTodos = useMemo((): { + content: string; + status: 'pending' | 'in_progress' | 'completed'; + summary?: string | null; + }[] => { // Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec; @@ -250,7 +254,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ return { content: task.description, status: effectiveStatus, - summary: realtimeSummary ?? task.summary, + summary: taskSummaryMap.has(task.id) ? realtimeSummary : task.summary, }; }); } diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx index 38b32e58..94bd4226 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx @@ -240,6 +240,12 @@ export const ListView = memo(function ListView({ const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts); const addFeatureShortcut = keyboardShortcuts.addFeature || 'N'; + // Effective sort config: when sortNewestCardOnTop is enabled, sort by createdAt desc + const effectiveSortConfig: SortConfig = useMemo( + () => (sortNewestCardOnTop ? { column: 'createdAt', direction: 'desc' } : sortConfig), + [sortNewestCardOnTop, sortConfig] + ); + // Generate status groups from columnFeaturesMap const statusGroups = useMemo(() => { // Effective sort config: when sortNewestCardOnTop is enabled, sort by createdAt desc @@ -454,7 +460,7 @@ export const ListView = memo(function ListView({ > {/* Table header */}
- +
+ + +