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:
159
packages/core/src/__tests__/notification-dispatcher.test.ts
Normal file
159
packages/core/src/__tests__/notification-dispatcher.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
89
packages/core/src/notification/dispatcher.ts
Normal file
89
packages/core/src/notification/dispatcher.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
3
packages/core/src/notification/index.ts
Normal file
3
packages/core/src/notification/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types.js";
|
||||
export * from "./provider.js";
|
||||
export * from "./dispatcher.js";
|
||||
13
packages/core/src/notification/provider.ts
Normal file
13
packages/core/src/notification/provider.ts
Normal 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>;
|
||||
}
|
||||
17
packages/core/src/notification/types.ts
Normal file
17
packages/core/src/notification/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>`. */
|
||||
|
||||
Reference in New Issue
Block a user