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:
5
packages/engine/src/notification/index.ts
Normal file
5
packages/engine/src/notification/index.ts
Normal 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";
|
||||
206
packages/engine/src/notification/notification-service.ts
Normal file
206
packages/engine/src/notification/notification-service.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
154
packages/engine/src/notification/ntfy-provider.ts
Normal file
154
packages/engine/src/notification/ntfy-provider.ts
Normal 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() };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user