feat(FN-2865): add notification provider abstractions

- Add shared notification event/payload/provider config types in core
- Introduce notification module exports with provider interface and dispatcher implementation
- Extend settings schema to accept notificationProviders configuration
- Add dispatcher unit coverage for provider filtering, failures, and delivery behavior
This commit is contained in:
Fusion
2026-04-28 08:53:36 -07:00
committed by gsxdsm
parent cb5c628f00
commit a481a68b16
8 changed files with 327 additions and 1 deletions

View File

@@ -0,0 +1,159 @@
import { describe, expect, it, vi } from "vitest";
import { NotificationDispatcher } from "../notification/dispatcher.js";
import type { NotificationProvider } from "../notification/provider.js";
import { DEFAULT_GLOBAL_SETTINGS } from "../settings-schema.js";
import type { NtfyNotificationEvent, NotificationPayload } from "../types.js";
function createProvider(overrides: Partial<NotificationProvider> = {}): NotificationProvider {
const providerId = overrides.getProviderId?.() ?? "provider-default";
return {
getProviderId: () => providerId,
isEventSupported: () => true,
sendNotification: async () => ({ success: true, providerId }),
...overrides,
};
}
describe("NotificationDispatcher", () => {
it("dispatches notifications to multiple providers and returns all results", async () => {
const first = createProvider({
getProviderId: () => "provider-1",
sendNotification: vi.fn(async () => ({ success: true, providerId: "provider-1" })),
});
const second = createProvider({
getProviderId: () => "provider-2",
sendNotification: vi.fn(async () => ({ success: true, providerId: "provider-2" })),
});
const dispatcher = new NotificationDispatcher();
dispatcher.registerProvider(first);
dispatcher.registerProvider(second);
const payload: NotificationPayload = { taskId: "FN-1", event: "in-review" };
const results = await dispatcher.dispatch("in-review", payload);
expect(first.sendNotification).toHaveBeenCalledWith("in-review", payload);
expect(second.sendNotification).toHaveBeenCalledWith("in-review", payload);
expect(results).toEqual([
{ success: true, providerId: "provider-1" },
{ success: true, providerId: "provider-2" },
]);
});
it("isolates provider failures so one exception does not block other providers", async () => {
const healthy = createProvider({
getProviderId: () => "healthy",
sendNotification: vi.fn(async () => ({ success: true, providerId: "healthy" })),
});
const broken = createProvider({
getProviderId: () => "broken",
sendNotification: vi.fn(async () => {
throw new Error("provider exploded");
}),
});
const dispatcher = new NotificationDispatcher();
dispatcher.registerProvider(broken);
dispatcher.registerProvider(healthy);
const results = await dispatcher.dispatch("failed", {
taskId: "FN-2",
event: "failed",
});
expect(healthy.sendNotification).toHaveBeenCalledTimes(1);
expect(results).toEqual([
{ success: false, providerId: "broken", error: "provider exploded" },
{ success: true, providerId: "healthy" },
]);
});
it("supports unregistering providers", async () => {
const removable = createProvider({
getProviderId: () => "removable",
sendNotification: vi.fn(async () => ({ success: true, providerId: "removable" })),
});
const dispatcher = new NotificationDispatcher();
dispatcher.registerProvider(removable);
dispatcher.unregisterProvider("removable");
const results = await dispatcher.dispatch("merged", {
taskId: "FN-3",
event: "merged",
});
expect(removable.sendNotification).not.toHaveBeenCalled();
expect(results).toEqual([]);
});
it("filters providers by isEventSupported", async () => {
const supported = createProvider({
getProviderId: () => "supported",
sendNotification: vi.fn(async () => ({ success: true, providerId: "supported" })),
isEventSupported: () => true,
});
const skipped = createProvider({
getProviderId: () => "skipped",
sendNotification: vi.fn(async () => ({ success: true, providerId: "skipped" })),
isEventSupported: () => false,
});
const dispatcher = new NotificationDispatcher();
dispatcher.registerProvider(supported);
dispatcher.registerProvider(skipped);
const results = await dispatcher.dispatch("awaiting-user-review", {
taskId: "FN-4",
event: "awaiting-user-review",
});
expect(supported.sendNotification).toHaveBeenCalledTimes(1);
expect(skipped.sendNotification).not.toHaveBeenCalled();
expect(results).toEqual([{ success: true, providerId: "supported" }]);
});
it("calls initialize and shutdown hooks only when defined", async () => {
const initialize = vi.fn(async () => {});
const shutdown = vi.fn(async () => {});
const lifecycle = createProvider({
getProviderId: () => "lifecycle",
initialize,
shutdown,
});
const noLifecycle = createProvider({
getProviderId: () => "no-lifecycle",
});
const dispatcher = new NotificationDispatcher({ maxRetries: 2, retryDelayMs: 10 });
dispatcher.registerProvider(lifecycle);
dispatcher.registerProvider(noLifecycle);
await dispatcher.initializeAll();
await dispatcher.shutdownAll();
expect(initialize).toHaveBeenCalledWith({ maxRetries: 2, retryDelayMs: 10 });
expect(shutdown).toHaveBeenCalledTimes(1);
});
it("keeps ntfy defaults unchanged and adds notificationProviders default", () => {
const event: NtfyNotificationEvent = "in-review";
expect(event).toBe("in-review");
expect(DEFAULT_GLOBAL_SETTINGS.ntfyEnabled).toBe(false);
expect(DEFAULT_GLOBAL_SETTINGS.ntfyTopic).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.ntfyBaseUrl).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.ntfyDashboardHost).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.ntfyEvents).toEqual([
"in-review",
"merged",
"failed",
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
"gridlock",
]);
expect(DEFAULT_GLOBAL_SETTINGS.notificationProviders).toEqual([]);
});
});

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,
@@ -106,6 +106,15 @@ export type {
export { RoutineStore } from "./routine-store.js";
export type { RoutineStoreEvents } from "./routine-store.js";
// ── Notification Provider System ────────────────────────────────
export type { NotificationProvider } from "./notification/provider.js";
export { NotificationDispatcher } from "./notification/dispatcher.js";
export type {
NotificationDispatcherConfig,
NotificationResult,
} from "./notification/types.js";
export { NOTIFICATION_EVENTS } from "./types.js";
// ── Plugin System ─────────────────────────────────────────────────────
export type {
PluginManifest,

View File

@@ -0,0 +1,89 @@
import type { NotificationProvider } from "./provider.js";
import type {
NotificationDispatcherConfig,
NotificationEvent,
NotificationPayload,
NotificationResult,
} from "./types.js";
export class NotificationDispatcher {
private readonly providers = new Map<string, NotificationProvider>();
constructor(private readonly config: NotificationDispatcherConfig = {}) {}
registerProvider(provider: NotificationProvider): void {
this.providers.set(provider.getProviderId(), provider);
}
unregisterProvider(providerId: string): void {
this.providers.delete(providerId);
}
getProviders(): readonly NotificationProvider[] {
return [...this.providers.values()];
}
async dispatch(
event: NotificationEvent,
payload: NotificationPayload,
): Promise<NotificationResult[]> {
const providers = this.getProviders().filter((provider) =>
provider.isEventSupported(event),
);
const results = await Promise.all(
providers.map(async (provider): Promise<NotificationResult> => {
const providerId = provider.getProviderId();
try {
return await provider.sendNotification(event, payload);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`[notification-dispatcher] Provider ${providerId} failed for event ${event}: ${message}`,
);
return { success: false, providerId, error: message };
}
}),
);
return results;
}
async initializeAll(): Promise<void> {
await Promise.all(
this.getProviders().map(async (provider) => {
if (!provider.initialize) {
return;
}
try {
await provider.initialize(this.config as Record<string, unknown>);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`[notification-dispatcher] Provider ${provider.getProviderId()} initialization failed: ${message}`,
);
}
}),
);
}
async shutdownAll(): Promise<void> {
await Promise.all(
this.getProviders().map(async (provider) => {
if (!provider.shutdown) {
return;
}
try {
await provider.shutdown();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`[notification-dispatcher] Provider ${provider.getProviderId()} shutdown failed: ${message}`,
);
}
}),
);
}
}

View File

@@ -0,0 +1,3 @@
export * from "./types.js";
export * from "./provider.js";
export * from "./dispatcher.js";

View File

@@ -0,0 +1,13 @@
import type { NotificationEvent, NotificationPayload } from "./types.js";
import type { NotificationResult } from "./types.js";
export interface NotificationProvider {
getProviderId(): string;
sendNotification(
event: NotificationEvent,
payload: NotificationPayload,
): Promise<NotificationResult>;
isEventSupported(event: NotificationEvent): boolean;
initialize?(config: Record<string, unknown>): Promise<void>;
shutdown?(): Promise<void>;
}

View File

@@ -0,0 +1,17 @@
export {
NOTIFICATION_EVENTS,
type NotificationEvent,
type NotificationPayload,
type NotificationProviderConfig,
} from "../types.js";
export interface NotificationResult {
success: boolean;
providerId: string;
error?: string;
}
export interface NotificationDispatcherConfig {
maxRetries?: number;
retryDelayMs?: number;
}

View File

@@ -24,6 +24,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
ntfyBaseUrl: undefined,
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input", "gridlock"],
ntfyDashboardHost: undefined,
notificationProviders: [],
defaultProjectId: undefined,
setupComplete: undefined,
favoriteProviders: undefined,

View File

@@ -154,6 +154,38 @@ export interface WorkflowStep {
/** Event types that can trigger ntfy notifications */
export type NtfyNotificationEvent = "in-review" | "merged" | "failed" | "awaiting-approval" | "awaiting-user-review" | "planning-awaiting-input" | "gridlock";
/** Known notification event types. Providers may support additional custom events. */
export const NOTIFICATION_EVENTS = [
"in-review",
"merged",
"failed",
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
"gridlock",
] as const;
/** Notification event type. Known events plus provider-specific custom events. */
export type NotificationEvent = (typeof NOTIFICATION_EVENTS)[number] | (string & {});
/** Standard payload shape shared across notification providers. */
export interface NotificationPayload {
taskId: string;
taskTitle?: string;
taskDescription?: string;
event: NotificationEvent;
timestamp?: string;
metadata?: Record<string, unknown>;
}
/** Declarative notification provider configuration persisted in settings. */
export interface NotificationProviderConfig {
id: string;
name: string;
enabled: boolean;
config: Record<string, unknown>;
}
export interface WorkflowStepInput {
/** Built-in template source ID when creating a concrete step from a template. */
templateId?: string;
@@ -1085,6 +1117,9 @@ export interface GlobalSettings {
* ?project=<id>&task=<id> so the dashboard opens the correct project first.
* Example: "http://localhost:3000" or "https://fusion.example.com" */
ntfyDashboardHost?: string;
/** Pluggable notification providers configuration. Additive to legacy ntfy
* settings so existing ntfy configuration continues working unchanged. */
notificationProviders?: NotificationProviderConfig[];
/** The default project ID for CLI operations when --project flag is not provided.
* Used to determine which project to operate on when not in a project directory.
* Set via `fn project set-default <name>`. */