feat(FN-2866): wire provider-backed notification service into engine

- Add notification service module with provider abstractions and ntfy provider implementation
- Refactor NtfyNotifier into a compatibility wrapper that delegates task-event delivery to NotificationService
- Initialize and stop NotificationService from ProjectEngine while preserving gridlock notifications via NtfyNotifier
- Export notification APIs from engine index and add focused unit coverage for provider, service, and project-engine wiring
This commit is contained in:
Fusion
2026-04-28 09:22:46 -07:00
committed by gsxdsm
parent a7670dd612
commit 7e83521f22
9 changed files with 629 additions and 158 deletions

View File

@@ -0,0 +1,5 @@
export { NtfyNotificationProvider } from "./ntfy-provider.js";
export type { NtfyProviderConfig } from "./ntfy-provider.js";
export { NotificationService } from "./notification-service.js";
export type { NotificationServiceOptions } from "./notification-service.js";

View File

@@ -0,0 +1,206 @@
import type {
Column,
MergeResult,
NotificationEvent,
NotificationPayload,
NotificationProvider,
Settings,
Task,
} from "@fusion/core";
import { NotificationDispatcher } from "@fusion/core";
import { DEFAULT_NTFY_EVENTS } from "../notifier.js";
import { schedulerLog } from "../logger.js";
import { NtfyNotificationProvider } from "./ntfy-provider.js";
export interface NotificationServiceOptions {
/** Project identifier for notification deep links */
projectId?: string;
/** Base URL for ntfy.sh (backward compat with NtfyNotifierOptions) */
ntfyBaseUrl?: string;
}
interface NotificationServiceStore {
getSettings(): Promise<Settings> | Settings;
on(event: string, listener: (...args: any[]) => void): void;
off(event: string, listener: (...args: any[]) => void): void;
}
export class NotificationService {
private readonly dispatcher = new NotificationDispatcher();
private readonly notifiedEvents = new Set<string>();
private started = false;
private ntfyEnabled = false;
private ntfyProvider?: NtfyNotificationProvider;
constructor(
private readonly store: NotificationServiceStore,
private readonly options: NotificationServiceOptions = {},
) {}
registerProvider(provider: NotificationProvider): void {
this.dispatcher.registerProvider(provider);
}
async start(): Promise<void> {
if (this.started) {
return;
}
const settings = await this.store.getSettings();
await this.syncNtfyProvider(settings);
await this.dispatcher.initializeAll();
this.store.on("task:moved", this.handleTaskMoved);
this.store.on("task:updated", this.handleTaskUpdated);
this.store.on("task:merged", this.handleTaskMerged);
this.store.on("settings:updated", this.handleSettingsUpdated);
this.started = true;
schedulerLog.log("NotificationService started");
}
async stop(): Promise<void> {
if (!this.started) {
return;
}
if (typeof this.store.off === "function") {
this.store.off("task:moved", this.handleTaskMoved);
this.store.off("task:updated", this.handleTaskUpdated);
this.store.off("task:merged", this.handleTaskMerged);
this.store.off("settings:updated", this.handleSettingsUpdated);
}
await this.dispatcher.shutdownAll();
this.started = false;
schedulerLog.log("NotificationService stopped");
}
private handleTaskMoved = (data: { task: Task; from: Column; to: Column }): void => {
if (!this.ntfyEnabled || data.to !== "in-review") {
return;
}
const payload = this.createTaskPayload(data.task, "in-review");
this.maybeNotify(data.task.id, "in-review", payload);
};
private handleTaskUpdated = (task: Task): void => {
if (!this.ntfyEnabled) {
return;
}
if (task.status === "failed") {
this.maybeNotify(task.id, "failed", this.createTaskPayload(task, "failed"));
}
if (task.status === "awaiting-approval") {
this.maybeNotify(
task.id,
"awaiting-approval",
this.createTaskPayload(task, "awaiting-approval"),
);
}
if (task.status === "awaiting-user-review") {
this.maybeNotify(
task.id,
"awaiting-user-review",
this.createTaskPayload(task, "awaiting-user-review"),
);
}
};
private handleTaskMerged = (result: MergeResult): void => {
if (!this.ntfyEnabled || !result.merged) {
return;
}
this.maybeNotify(
result.task.id,
"merged",
this.createTaskPayload(result.task, "merged"),
);
};
private handleSettingsUpdated = async (data: { settings: Settings; previous: Settings }): Promise<void> => {
const { settings, previous } = data;
if (
settings.ntfyEnabled !== previous.ntfyEnabled ||
settings.ntfyTopic !== previous.ntfyTopic ||
settings.ntfyBaseUrl !== previous.ntfyBaseUrl ||
settings.ntfyDashboardHost !== previous.ntfyDashboardHost ||
JSON.stringify(settings.ntfyEvents) !== JSON.stringify(previous.ntfyEvents)
) {
const wasEnabled = Boolean(previous.ntfyEnabled && previous.ntfyTopic);
const isEnabled = Boolean(settings.ntfyEnabled && settings.ntfyTopic);
await this.syncNtfyProvider(settings);
if (isEnabled && !wasEnabled) {
schedulerLog.log("NotificationService ntfy enabled");
} else if (!isEnabled && wasEnabled) {
schedulerLog.log("NotificationService ntfy disabled");
} else if (settings.ntfyTopic !== previous.ntfyTopic) {
schedulerLog.log("NotificationService ntfy topic updated");
} else if (settings.ntfyBaseUrl !== previous.ntfyBaseUrl) {
schedulerLog.log("NotificationService ntfy base URL updated");
} else if (settings.ntfyDashboardHost !== previous.ntfyDashboardHost) {
schedulerLog.log("NotificationService ntfy dashboard host updated");
} else if (JSON.stringify(settings.ntfyEvents) !== JSON.stringify(previous.ntfyEvents)) {
schedulerLog.log("NotificationService ntfy events updated");
}
}
};
private async syncNtfyProvider(settings: Settings): Promise<void> {
const enabled = Boolean(settings.ntfyEnabled && settings.ntfyTopic);
this.ntfyEnabled = enabled;
if (!enabled) {
if (this.ntfyProvider) {
await this.ntfyProvider.shutdown?.();
this.dispatcher.unregisterProvider(this.ntfyProvider.getProviderId());
this.ntfyProvider = undefined;
}
return;
}
if (!this.ntfyProvider) {
this.ntfyProvider = new NtfyNotificationProvider();
this.registerProvider(this.ntfyProvider);
}
await this.ntfyProvider.initialize?.({
topic: settings.ntfyTopic,
ntfyBaseUrl: settings.ntfyBaseUrl ?? this.options.ntfyBaseUrl,
dashboardHost: settings.ntfyDashboardHost,
events: settings.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS],
projectId: this.options.projectId,
});
}
private createTaskPayload(task: Task, event: NotificationEvent): NotificationPayload {
return {
taskId: task.id,
taskTitle: task.title,
taskDescription: task.description,
event,
};
}
private maybeNotify(taskId: string, eventType: NotificationEvent, payload: NotificationPayload): void {
const key = `${taskId}:${eventType}`;
if (this.notifiedEvents.has(key)) {
return;
}
this.notifiedEvents.add(key);
this.dispatcher.dispatch(eventType, payload).catch(() => {
// best effort dispatch
});
}
}

View File

@@ -0,0 +1,154 @@
import type {
NotificationEvent,
NotificationPayload,
NotificationProvider,
NotificationResult,
NtfyNotificationEvent,
Task,
} from "@fusion/core";
import {
DEFAULT_NTFY_EVENTS,
buildNtfyClickUrl,
formatTaskIdentifier,
resolveNtfyEvents,
sendNtfyNotification,
} from "../notifier.js";
export interface NtfyProviderConfig {
/** ntfy topic name */
topic: string;
/** ntfy server base URL (default: https://ntfy.sh) */
ntfyBaseUrl?: string;
/** Dashboard host for click-through deep links */
dashboardHost?: string;
/** Project identifier for deep links */
projectId?: string;
/** Events to enable (default: DEFAULT_NTFY_EVENTS) */
events?: NtfyNotificationEvent[];
}
type SupportedNtfyEvent =
| "in-review"
| "merged"
| "failed"
| "awaiting-approval"
| "awaiting-user-review"
| "planning-awaiting-input";
const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
"in-review",
"merged",
"failed",
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
]);
export class NtfyNotificationProvider implements NotificationProvider {
private config?: NtfyProviderConfig;
private abortController: AbortController | null = null;
getProviderId(): string {
return "ntfy";
}
async initialize(config: Record<string, unknown>): Promise<void> {
if (typeof config.topic !== "string" || config.topic.trim() === "") {
return;
}
this.config = config as unknown as NtfyProviderConfig;
this.config.events = resolveNtfyEvents(this.config.events);
this.abortController = new AbortController();
}
async shutdown(): Promise<void> {
this.abortController?.abort();
this.abortController = null;
}
isEventSupported(event: NotificationEvent): boolean {
if (!SUPPORTED_EVENTS.has(event as SupportedNtfyEvent)) {
return false;
}
const enabledEvents = this.config?.events ?? [...DEFAULT_NTFY_EVENTS];
return enabledEvents.includes(event as NtfyNotificationEvent);
}
async sendNotification(
event: NotificationEvent,
payload: NotificationPayload,
): Promise<NotificationResult> {
if (!this.config?.topic) {
return { success: false, providerId: this.getProviderId(), error: "ntfy topic not configured" };
}
if (!this.isEventSupported(event)) {
return {
success: false,
providerId: this.getProviderId(),
error: `unsupported event: ${event}`,
};
}
const taskLike = {
id: payload.taskId,
title: payload.taskTitle,
description: payload.taskDescription ?? "",
} as Pick<Task, "id" | "title" | "description"> as Task;
const identifier = formatTaskIdentifier(taskLike);
const clickUrl = buildNtfyClickUrl({
dashboardHost: this.config.dashboardHost,
projectId: this.config.projectId,
taskId: payload.taskId,
});
const contentByEvent: Record<SupportedNtfyEvent, { title: string; message: string; priority: "default" | "high" }> = {
"in-review": {
title: `Task ${payload.taskId} completed`,
message: `Task "${identifier}" is ready for review`,
priority: "default",
},
merged: {
title: `Task ${payload.taskId} merged`,
message: `Task "${identifier}" has been merged to main`,
priority: "default",
},
failed: {
title: `Task ${payload.taskId} failed`,
message: `Task "${identifier}" has failed and needs attention`,
priority: "high",
},
"awaiting-approval": {
title: `Plan needs approval for ${payload.taskId}`,
message: `Task "${identifier}" needs your approval before it can proceed`,
priority: "high",
},
"awaiting-user-review": {
title: `User review needed for ${payload.taskId}`,
message: `Task "${identifier}" needs human review before it can proceed`,
priority: "high",
},
"planning-awaiting-input": {
title: `Planning input needed for ${payload.taskId}`,
message: `Task "${identifier}" is awaiting your input during planning`,
priority: "high",
},
};
const content = contentByEvent[event as SupportedNtfyEvent];
await sendNtfyNotification({
ntfyBaseUrl: this.config.ntfyBaseUrl,
topic: this.config.topic,
title: content.title,
message: content.message,
priority: content.priority,
clickUrl,
signal: this.abortController?.signal,
});
return { success: true, providerId: this.getProviderId() };
}
}