feat(FN-2340): merge fusion/fn-2340
This commit is contained in:
@@ -8,6 +8,46 @@ export interface NtfyNotifierOptions {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export type NtfyNotificationPriority = "low" | "default" | "high" | "urgent";
|
||||
|
||||
export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
|
||||
"in-review",
|
||||
"merged",
|
||||
"failed",
|
||||
"awaiting-approval",
|
||||
"awaiting-user-review",
|
||||
"planning-awaiting-input",
|
||||
] as const;
|
||||
|
||||
export interface NtfyNotificationConfigInput {
|
||||
enabled?: boolean;
|
||||
topic?: string;
|
||||
dashboardHost?: string;
|
||||
events?: NtfyNotificationEvent[];
|
||||
projectId?: string;
|
||||
ntfyBaseUrl?: string;
|
||||
}
|
||||
|
||||
export interface SendNtfyNotificationInput {
|
||||
ntfyBaseUrl?: string;
|
||||
topic: string;
|
||||
title: string;
|
||||
message: string;
|
||||
priority?: NtfyNotificationPriority;
|
||||
clickUrl?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface NtfyConfig {
|
||||
enabled: boolean;
|
||||
topic: string | undefined;
|
||||
dashboardHost: string | undefined;
|
||||
events: NtfyNotificationEvent[];
|
||||
}
|
||||
|
||||
/** Event types for task notification deduplication */
|
||||
type TaskNotificationEvent = "in-review" | "merged" | "failed" | "awaiting-approval" | "awaiting-user-review";
|
||||
|
||||
/**
|
||||
* Format a task identifier for notifications.
|
||||
* - If title exists: returns "{title}"
|
||||
@@ -31,35 +71,95 @@ interface NtfyNotifierStore {
|
||||
off(event: string, listener: (...args: any[]) => void): void;
|
||||
}
|
||||
|
||||
interface NtfyConfig {
|
||||
enabled: boolean;
|
||||
topic: string | undefined;
|
||||
dashboardHost: string | undefined;
|
||||
events: NtfyNotificationEvent[];
|
||||
export function resolveNtfyEvents(events?: NtfyNotificationEvent[]): NtfyNotificationEvent[] {
|
||||
return events ? [...events] : [...DEFAULT_NTFY_EVENTS];
|
||||
}
|
||||
|
||||
/** Event types for notification deduplication */
|
||||
type NotificationEventType = "in-review" | "merged" | "failed" | "awaiting-approval" | "awaiting-user-review";
|
||||
export function isNtfyEventEnabled(events: NtfyNotificationEvent[] | undefined, event: NtfyNotificationEvent): boolean {
|
||||
return resolveNtfyEvents(events).includes(event);
|
||||
}
|
||||
|
||||
export function buildNtfyClickUrl(options: {
|
||||
dashboardHost?: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
}): string | undefined {
|
||||
const { dashboardHost, projectId, taskId } = options;
|
||||
if (!dashboardHost) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedHost = dashboardHost.replace(/\/+$/, "");
|
||||
const queryParts: string[] = [];
|
||||
|
||||
if (projectId) {
|
||||
queryParts.push(`project=${encodeURIComponent(projectId)}`);
|
||||
}
|
||||
if (taskId) {
|
||||
queryParts.push(`task=${encodeURIComponent(taskId)}`);
|
||||
}
|
||||
|
||||
const query = queryParts.join("&");
|
||||
return query ? `${normalizedHost}/?${query}` : `${normalizedHost}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to ntfy.
|
||||
* Errors are logged and swallowed so callers can treat delivery as best-effort.
|
||||
*/
|
||||
export async function sendNtfyNotification({
|
||||
ntfyBaseUrl = "https://ntfy.sh",
|
||||
topic,
|
||||
title,
|
||||
message,
|
||||
priority = "default",
|
||||
clickUrl,
|
||||
signal,
|
||||
}: SendNtfyNotificationInput): Promise<void> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Title: title,
|
||||
Priority: priority,
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
|
||||
if (clickUrl) {
|
||||
headers.Click = clickUrl;
|
||||
}
|
||||
|
||||
const response = await fetch(`${ntfyBaseUrl}/${topic}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: message,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
schedulerLog.log(`Ntfy notification failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
schedulerLog.log(`Failed to send ntfy notification: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NtfyNotifier sends push notifications via ntfy.sh when tasks complete
|
||||
* or fail. It listens to TaskStore events and sends HTTP POST requests
|
||||
* to the configured ntfy topic.
|
||||
*
|
||||
* Features:
|
||||
* - Runtime reconfiguration via settings:updated events
|
||||
* - Best-effort delivery (errors are logged but never thrown)
|
||||
* - Duplicate prevention per event type (in-review, merged, failed, awaiting-approval)
|
||||
* - Configurable notification events (hardcoded defaults)
|
||||
*/
|
||||
export class NtfyNotifier {
|
||||
private config: NtfyConfig = { enabled: false, topic: undefined, dashboardHost: undefined, events: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] };
|
||||
private config: NtfyConfig = {
|
||||
enabled: false,
|
||||
topic: undefined,
|
||||
dashboardHost: undefined,
|
||||
events: [...DEFAULT_NTFY_EVENTS],
|
||||
};
|
||||
private ntfyBaseUrl: string;
|
||||
/** Project identifier for deep links in notifications */
|
||||
private projectId?: string;
|
||||
/** Tracks which (taskId, eventType) pairs have been notified to prevent duplicates */
|
||||
private notifiedEvents: Set<string> = new Set();
|
||||
/** AbortController for in-flight requests during shutdown */
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -70,36 +170,20 @@ export class NtfyNotifier {
|
||||
this.projectId = options.projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening to store events.
|
||||
* Must be called after store is initialized.
|
||||
* Returns a promise that resolves when initial config is loaded.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
this.abortController = new AbortController();
|
||||
|
||||
// Load initial config
|
||||
const settings = await this.store.getSettings();
|
||||
this.loadConfig(settings);
|
||||
|
||||
// Listen for task movements
|
||||
this.store.on("task:moved", this.handleTaskMoved);
|
||||
|
||||
// Listen for task updates (status changes)
|
||||
this.store.on("task:updated", this.handleTaskUpdated);
|
||||
|
||||
// Listen for merge events
|
||||
this.store.on("task:merged", this.handleTaskMerged);
|
||||
|
||||
// Listen for settings changes for runtime reconfiguration
|
||||
this.store.on("settings:updated", this.handleSettingsUpdated);
|
||||
|
||||
schedulerLog.log("NtfyNotifier started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening to store events and abort in-flight requests.
|
||||
*/
|
||||
stop(): void {
|
||||
if (typeof this.store.off === "function") {
|
||||
this.store.off("task:moved", this.handleTaskMoved);
|
||||
@@ -108,7 +192,6 @@ export class NtfyNotifier {
|
||||
this.store.off("settings:updated", this.handleSettingsUpdated);
|
||||
}
|
||||
|
||||
// Abort any in-flight requests
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
@@ -122,66 +205,83 @@ export class NtfyNotifier {
|
||||
|
||||
const { task, to } = data;
|
||||
|
||||
// Notify when task moves to in-review (completed work, ready for review)
|
||||
if (to === "in-review" && this.isEventEnabled("in-review")) {
|
||||
const clickUrl = this.buildTaskUrl(task.id);
|
||||
const clickUrl = buildNtfyClickUrl({
|
||||
dashboardHost: this.config.dashboardHost,
|
||||
projectId: this.projectId,
|
||||
taskId: task.id,
|
||||
});
|
||||
this.maybeNotify(task.id, "in-review", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${task.id} completed`,
|
||||
`Task "${formatTaskIdentifier(task)}" is ready for review`,
|
||||
"default",
|
||||
sendNtfyNotification({
|
||||
ntfyBaseUrl: this.ntfyBaseUrl,
|
||||
topic: this.config.topic!,
|
||||
title: `Task ${task.id} completed`,
|
||||
message: `Task "${formatTaskIdentifier(task)}" is ready for review`,
|
||||
priority: "default",
|
||||
clickUrl,
|
||||
),
|
||||
signal: this.abortController?.signal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Note: "done" notifications come from handleTaskMerged (task:merged event)
|
||||
// to avoid duplicate notifications when moveToDone is called before merge
|
||||
};
|
||||
|
||||
private handleTaskUpdated = (task: Task): void => {
|
||||
if (!this.config.enabled || !this.config.topic) return;
|
||||
|
||||
// Notify when task fails
|
||||
if (task.status === "failed" && this.isEventEnabled("failed")) {
|
||||
const clickUrl = this.buildTaskUrl(task.id);
|
||||
const clickUrl = buildNtfyClickUrl({
|
||||
dashboardHost: this.config.dashboardHost,
|
||||
projectId: this.projectId,
|
||||
taskId: task.id,
|
||||
});
|
||||
this.maybeNotify(task.id, "failed", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${task.id} failed`,
|
||||
`Task "${formatTaskIdentifier(task)}" has failed and needs attention`,
|
||||
"high",
|
||||
sendNtfyNotification({
|
||||
ntfyBaseUrl: this.ntfyBaseUrl,
|
||||
topic: this.config.topic!,
|
||||
title: `Task ${task.id} failed`,
|
||||
message: `Task "${formatTaskIdentifier(task)}" has failed and needs attention`,
|
||||
priority: "high",
|
||||
clickUrl,
|
||||
),
|
||||
signal: this.abortController?.signal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Notify when task requires manual plan approval
|
||||
if (task.status === "awaiting-approval" && this.isEventEnabled("awaiting-approval")) {
|
||||
const clickUrl = this.buildTaskUrl(task.id);
|
||||
const clickUrl = buildNtfyClickUrl({
|
||||
dashboardHost: this.config.dashboardHost,
|
||||
projectId: this.projectId,
|
||||
taskId: task.id,
|
||||
});
|
||||
this.maybeNotify(task.id, "awaiting-approval", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Plan needs approval for ${task.id}`,
|
||||
`Task "${formatTaskIdentifier(task)}" needs your approval before it can proceed`,
|
||||
"high",
|
||||
sendNtfyNotification({
|
||||
ntfyBaseUrl: this.ntfyBaseUrl,
|
||||
topic: this.config.topic!,
|
||||
title: `Plan needs approval for ${task.id}`,
|
||||
message: `Task "${formatTaskIdentifier(task)}" needs your approval before it can proceed`,
|
||||
priority: "high",
|
||||
clickUrl,
|
||||
),
|
||||
signal: this.abortController?.signal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Notify when task needs human review (agent handoff to user)
|
||||
if (task.status === "awaiting-user-review" && this.isEventEnabled("awaiting-user-review")) {
|
||||
const clickUrl = this.buildTaskUrl(task.id);
|
||||
const clickUrl = buildNtfyClickUrl({
|
||||
dashboardHost: this.config.dashboardHost,
|
||||
projectId: this.projectId,
|
||||
taskId: task.id,
|
||||
});
|
||||
this.maybeNotify(task.id, "awaiting-user-review", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`User review needed for ${task.id}`,
|
||||
`Task "${formatTaskIdentifier(task)}" needs human review before it can proceed`,
|
||||
"high",
|
||||
sendNtfyNotification({
|
||||
ntfyBaseUrl: this.ntfyBaseUrl,
|
||||
topic: this.config.topic!,
|
||||
title: `User review needed for ${task.id}`,
|
||||
message: `Task "${formatTaskIdentifier(task)}" needs human review before it can proceed`,
|
||||
priority: "high",
|
||||
clickUrl,
|
||||
),
|
||||
signal: this.abortController?.signal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -189,17 +289,22 @@ export class NtfyNotifier {
|
||||
private handleTaskMerged = (result: MergeResult): void => {
|
||||
if (!this.config.enabled || !this.config.topic) return;
|
||||
|
||||
// Only notify on successful merges
|
||||
if (result.merged && this.isEventEnabled("merged")) {
|
||||
const clickUrl = this.buildTaskUrl(result.task.id);
|
||||
const clickUrl = buildNtfyClickUrl({
|
||||
dashboardHost: this.config.dashboardHost,
|
||||
projectId: this.projectId,
|
||||
taskId: result.task.id,
|
||||
});
|
||||
this.maybeNotify(result.task.id, "merged", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${result.task.id} merged`,
|
||||
`Task "${formatTaskIdentifier(result.task)}" has been merged to main`,
|
||||
"default",
|
||||
sendNtfyNotification({
|
||||
ntfyBaseUrl: this.ntfyBaseUrl,
|
||||
topic: this.config.topic!,
|
||||
title: `Task ${result.task.id} merged`,
|
||||
message: `Task "${formatTaskIdentifier(result.task)}" has been merged to main`,
|
||||
priority: "default",
|
||||
clickUrl,
|
||||
),
|
||||
signal: this.abortController?.signal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -207,7 +312,6 @@ export class NtfyNotifier {
|
||||
private handleSettingsUpdated = (data: { settings: Settings; previous: Settings }): void => {
|
||||
const { settings, previous } = data;
|
||||
|
||||
// Check if ntfy settings changed
|
||||
if (settings.ntfyEnabled !== previous.ntfyEnabled ||
|
||||
settings.ntfyTopic !== previous.ntfyTopic ||
|
||||
settings.ntfyDashboardHost !== previous.ntfyDashboardHost ||
|
||||
@@ -234,105 +338,31 @@ export class NtfyNotifier {
|
||||
enabled: settings.ntfyEnabled ?? false,
|
||||
topic: settings.ntfyTopic,
|
||||
dashboardHost: settings.ntfyDashboardHost,
|
||||
events: settings.ntfyEvents ?? ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
|
||||
events: resolveNtfyEvents(settings.ntfyEvents),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a notification event type is enabled based on the configured events list.
|
||||
*/
|
||||
private isEventEnabled(event: NotificationEventType): boolean {
|
||||
return this.config.events.includes(event);
|
||||
private isEventEnabled(event: TaskNotificationEvent): boolean {
|
||||
return isNtfyEventEnabled(this.config.events, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a dashboard URL for deep linking to a task.
|
||||
* Returns undefined if dashboardHost is not configured.
|
||||
* Includes projectId in the URL when configured for multi-project support.
|
||||
*/
|
||||
private buildTaskUrl(taskId: string): string | undefined {
|
||||
if (!this.config.dashboardHost) {
|
||||
return undefined;
|
||||
}
|
||||
// Strip trailing slash from hostname if present
|
||||
const host = this.config.dashboardHost.replace(/\/$/, "");
|
||||
if (this.projectId) {
|
||||
return `${host}/?project=${encodeURIComponent(this.projectId)}&task=${encodeURIComponent(taskId)}`;
|
||||
}
|
||||
return `${host}/?task=${encodeURIComponent(taskId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification if this (taskId, eventType) pair hasn't been notified before.
|
||||
* This prevents duplicate notifications for the same event type per task.
|
||||
*/
|
||||
private maybeNotify(
|
||||
taskId: string,
|
||||
eventType: NotificationEventType,
|
||||
eventType: TaskNotificationEvent,
|
||||
notifyFn: () => Promise<void>,
|
||||
): void {
|
||||
const key = `${taskId}:${eventType}`;
|
||||
|
||||
if (this.notifiedEvents.has(key)) {
|
||||
// Already sent this notification type for this task
|
||||
return;
|
||||
}
|
||||
|
||||
this.notifiedEvents.add(key);
|
||||
notifyFn().catch(() => {
|
||||
// Errors are logged in sendNotification, just need to catch here
|
||||
// sendNtfyNotification already logs; notifier must stay best-effort
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to ntfy.sh.
|
||||
* Errors are caught and logged, never thrown.
|
||||
*/
|
||||
private async sendNotification(
|
||||
topic: string,
|
||||
title: string,
|
||||
message: string,
|
||||
priority: "low" | "default" | "high" | "urgent" = "default",
|
||||
clickUrl?: string,
|
||||
): Promise<void> {
|
||||
const url = `${this.ntfyBaseUrl}/${topic}`;
|
||||
const signal = this.abortController?.signal;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Title": title,
|
||||
"Priority": priority,
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
|
||||
// Add Click header for deep linking if URL is provided
|
||||
if (clickUrl) {
|
||||
headers["Click"] = clickUrl;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: message,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
schedulerLog.log(`Ntfy notification failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Don't throw - notifications are best-effort
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// Expected during shutdown
|
||||
return;
|
||||
}
|
||||
schedulerLog.log(`Failed to send ntfy notification: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current config (for testing purposes).
|
||||
*/
|
||||
getConfig(): NtfyConfig {
|
||||
return { ...this.config, events: [...this.config.events] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user