Improve auto-loop event emission and add ntfy notifications (#821)

This commit is contained in:
gsxdsm
2026-03-01 00:12:22 -08:00
committed by GitHub
parent 63b0a4fb38
commit 57bcb2802d
53 changed files with 4620 additions and 255 deletions

View File

@@ -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<boolean> {
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;
}
}
}

View File

@@ -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<void> {
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
*/

View File

@@ -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<void> {
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<string | null> {
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
*

View File

@@ -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<string, string> = {
'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<string, string>, 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<string, string> = {
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();

View File

@@ -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<string, unknown>).__allowEmptyNtfyEndpoints === true;
// Remove the flag so it doesn't get persisted
delete (sanitizedUpdates as Record<string, unknown>).__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 = <K extends keyof GlobalSettings>(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[]) || [],