feat(FN-2340): merge fusion/fn-2340

This commit is contained in:
gsxdsm
2026-04-23 22:57:21 -07:00
parent 9ddb475947
commit 9bf2981b22
13 changed files with 592 additions and 203 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Add a `planning-awaiting-input` ntfy notification event so users can opt in to alerts when planning sessions pause for user input.

View File

@@ -40,7 +40,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high"` | `undefined` | Default reasoning effort for AI sessions. |
| `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. |
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review"]` | Event types that trigger ntfy notifications. |
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. |
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
| `defaultProjectId` | `string` | `undefined` | Default project for multi-project CLI operations when `--project` is omitted. |
| `setupComplete` | `boolean` | `undefined` | Tracks completion of first-run setup. |

View File

@@ -273,6 +273,18 @@ describe("GlobalSettingsStore", () => {
expect(settings.ntfyDashboardHost).toBeUndefined();
});
it("persists custom ntfy event lists including planning-awaiting-input", async () => {
await store.init();
await store.updateSettings({ ntfyEvents: ["planning-awaiting-input", "failed"] });
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.ntfyEvents).toEqual(["planning-awaiting-input", "failed"]);
const settings = await store.getSettings();
expect(settings.ntfyEvents).toEqual(["planning-awaiting-input", "failed"]);
});
it("clearing ntfyEvents with null resets to default on read", async () => {
await store.init();
await store.updateSettings({ ntfyEvents: ["in-review", "failed"] });
@@ -287,7 +299,7 @@ describe("GlobalSettingsStore", () => {
// After clear, reading back gives the default value
// (either undefined on disk with default applied, or default written directly)
const settings = await store.getSettings();
expect(settings.ntfyEvents).toEqual(["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"]);
expect(settings.ntfyEvents).toEqual(["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"]);
});
it("handles concurrent updates safely via locking", async () => {

View File

@@ -21,7 +21,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
defaultThinkingLevel: undefined,
ntfyEnabled: false,
ntfyTopic: undefined,
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
ntfyDashboardHost: undefined,
defaultProjectId: undefined,
setupComplete: undefined,

View File

@@ -140,7 +140,7 @@ export interface WorkflowStep {
/** Input for creating a new workflow step. */
/** Event types that can trigger ntfy notifications */
export type NtfyNotificationEvent = "in-review" | "merged" | "failed" | "awaiting-approval" | "awaiting-user-review";
export type NtfyNotificationEvent = "in-review" | "merged" | "failed" | "awaiting-approval" | "awaiting-user-review" | "planning-awaiting-input";
export interface WorkflowStepInput {
/** Built-in template source ID when creating a concrete step from a template. */

View File

@@ -79,6 +79,14 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const AUTO_ARCHIVE_DEFAULT_AFTER_DAYS = 2;
const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [
"in-review",
"merged",
"failed",
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
];
/** Well-known experimental feature flags with display labels.
* These always appear in the Experimental Features settings tab,
@@ -2824,7 +2832,7 @@ export function SettingsModal({
type="checkbox"
checked={form.ntfyEvents?.includes("in-review") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("in-review") ? current : [...current, "in-review" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "in-review");
@@ -2840,7 +2848,7 @@ export function SettingsModal({
type="checkbox"
checked={form.ntfyEvents?.includes("merged") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("merged") ? current : [...current, "merged" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "merged");
@@ -2856,7 +2864,7 @@ export function SettingsModal({
type="checkbox"
checked={form.ntfyEvents?.includes("failed") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("failed") ? current : [...current, "failed" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "failed");
@@ -2872,7 +2880,7 @@ export function SettingsModal({
type="checkbox"
checked={form.ntfyEvents?.includes("awaiting-approval") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("awaiting-approval") ? current : [...current, "awaiting-approval" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "awaiting-approval");
@@ -2888,7 +2896,7 @@ export function SettingsModal({
type="checkbox"
checked={form.ntfyEvents?.includes("awaiting-user-review") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("awaiting-user-review") ? current : [...current, "awaiting-user-review" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "awaiting-user-review");
@@ -2898,6 +2906,22 @@ export function SettingsModal({
User review needed
</label>
<small>When an agent hands off a task for human review (high priority)</small>
<label className="checkbox-label">
<input
type="checkbox"
checked={form.ntfyEvents?.includes("planning-awaiting-input") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("planning-awaiting-input") ? current : [...current, "planning-awaiting-input" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "planning-awaiting-input");
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
Planning needs input
</label>
<small>When planning mode is waiting for your response to continue</small>
</div>
</div>
<div className="form-group">

View File

@@ -46,7 +46,7 @@ const defaultSettings: SettingsWithAutoArchive = {
defaultPresetBySize: {},
ntfyEnabled: false,
ntfyTopic: undefined,
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
taskStuckTimeoutMs: undefined,
maxStuckKills: 6,
specStalenessEnabled: false,
@@ -2495,7 +2495,7 @@ describe("SettingsModal", () => {
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
@@ -2507,6 +2507,7 @@ describe("SettingsModal", () => {
expect(screen.getByLabelText("Task failed")).toBeTruthy();
expect(screen.getByLabelText("Plan needs approval")).toBeTruthy();
expect(screen.getByLabelText("User review needed")).toBeTruthy();
expect(screen.getByLabelText("Planning needs input")).toBeTruthy();
});
it("shows awaiting-approval checkbox when ntfy is enabled", async () => {
@@ -2514,7 +2515,7 @@ describe("SettingsModal", () => {
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
@@ -2534,6 +2535,7 @@ describe("SettingsModal", () => {
expect(screen.queryByLabelText("Task failed")).toBeNull();
expect(screen.queryByLabelText("Plan needs approval")).toBeNull();
expect(screen.queryByLabelText("User review needed")).toBeNull();
expect(screen.queryByLabelText("Planning needs input")).toBeNull();
});
it("ntfyEvents checkboxes are all checked by default", async () => {
@@ -2552,6 +2554,7 @@ describe("SettingsModal", () => {
expect((screen.getByLabelText("Task failed") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Plan needs approval") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("User review needed") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Planning needs input") as HTMLInputElement).checked).toBe(true);
});
it("saves ntfyEvents correctly when checkboxes are toggled", async () => {
@@ -2559,7 +2562,7 @@ describe("SettingsModal", () => {
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
@@ -2575,7 +2578,13 @@ describe("SettingsModal", () => {
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.ntfyEvents).toEqual(["in-review", "failed", "awaiting-approval", "awaiting-user-review"]);
expect(payload.ntfyEvents).toEqual([
"in-review",
"failed",
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
]);
});
it("sets ntfyEvents to null when all checkboxes are unchecked (null-as-delete)", async () => {
@@ -2583,7 +2592,7 @@ describe("SettingsModal", () => {
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
@@ -2591,12 +2600,13 @@ describe("SettingsModal", () => {
fireEvent.click(screen.getByText("Notifications"));
// Uncheck all five
// Uncheck all six
fireEvent.click(screen.getByLabelText("Task completed (in-review)"));
fireEvent.click(screen.getByLabelText("Task merged"));
fireEvent.click(screen.getByLabelText("Task failed"));
fireEvent.click(screen.getByLabelText("Plan needs approval"));
fireEvent.click(screen.getByLabelText("User review needed"));
fireEvent.click(screen.getByLabelText("Planning needs input"));
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
@@ -2621,6 +2631,7 @@ describe("SettingsModal", () => {
expect((screen.getByLabelText("Task merged") as HTMLInputElement).checked).toBe(false);
expect((screen.getByLabelText("Task failed") as HTMLInputElement).checked).toBe(false);
expect((screen.getByLabelText("Plan needs approval") as HTMLInputElement).checked).toBe(false);
expect((screen.getByLabelText("Planning needs input") as HTMLInputElement).checked).toBe(false);
});
// Node Sync section tests

View File

@@ -22,6 +22,7 @@ import {
__resetPlanningState,
__setCreateFnAgent,
__setPlanningDiagnostics,
__setPlanningNtfyHelpers,
rehydrateFromStore,
setAiSessionStore,
RateLimitError,
@@ -115,6 +116,12 @@ function getUniqueIp(): string {
return `127.0.0.${++ipCounter}`;
}
async function flushAsyncWork(): Promise<void> {
await vi.waitFor(() => {
expect(true).toBe(true);
});
}
/**
* Helper: set up a fresh mock agent for the next createSession call.
* Returns the agent so tests can inspect `.session.prompt` calls.
@@ -158,6 +165,20 @@ function setupMockStreamingAgent(options?: {
return { createFnAgentSpy };
}
function setupMockPlanningNtfyHelpers(options?: { enabledEvent?: boolean; clickUrl?: string }) {
const sendNtfyNotification = vi.fn(async () => undefined);
const isNtfyEventEnabled = vi.fn(() => options?.enabledEvent ?? true);
const buildNtfyClickUrl = vi.fn(() => options?.clickUrl ?? "http://localhost:4040/?project=proj-123");
__setPlanningNtfyHelpers({
sendNtfyNotification,
isNtfyEventEnabled,
buildNtfyClickUrl,
});
return { sendNtfyNotification, isNtfyEventEnabled, buildNtfyClickUrl };
}
class MockAiSessionStore extends EventEmitter {
rows = new Map<string, AiSessionRow>();
@@ -266,6 +287,7 @@ describe("planning module", () => {
afterEach(() => {
__setCreateFnAgent(undefined as any);
__setPlanningNtfyHelpers(undefined);
});
describe("createSession", () => {
@@ -553,6 +575,136 @@ describe("planning module", () => {
resetDiagnosticsSink();
}
});
it("persists projectId across planning session state transitions", async () => {
const store = new MockAiSessionStore();
setAiSessionStore(store as any);
setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES });
const sessionId = await createSessionWithAgent(
getUniqueIp(),
"Build auth system",
TEST_ROOT_DIR,
undefined,
undefined,
undefined,
{
projectId: "proj-123",
ntfyConfig: { enabled: false, topic: "planning-topic" },
},
);
await vi.waitFor(() => {
expect(store.get(sessionId)?.status).toBe("awaiting_input");
});
expect(store.get(sessionId)?.projectId).toBe("proj-123");
await submitResponse(sessionId, { "q-scope": "medium" }, TEST_ROOT_DIR);
await vi.waitFor(() => {
expect(store.get(sessionId)?.status).toBe("awaiting_input");
});
expect(store.get(sessionId)?.projectId).toBe("proj-123");
await submitResponse(sessionId, { "q-requirements": "Must support SSO" }, TEST_ROOT_DIR);
await submitResponse(sessionId, { "q-confirm": true }, TEST_ROOT_DIR);
await vi.waitFor(() => {
expect(store.get(sessionId)?.status).toBe("complete");
});
expect(store.get(sessionId)?.projectId).toBe("proj-123");
});
it("sends planning awaiting-input notifications once per question and allows later distinct questions", async () => {
const firstQuestion = JSON.stringify({
type: "question",
data: { id: "q-1", type: "text", question: "First question?", description: "one" },
});
const repeatedQuestion = JSON.stringify({
type: "question",
data: { id: "q-1", type: "text", question: "First question?", description: "one" },
});
const secondQuestion = JSON.stringify({
type: "question",
data: { id: "q-2", type: "text", question: "Second question?", description: "two" },
});
setupMockStreamingAgent({ responses: [firstQuestion, repeatedQuestion, secondQuestion] });
const { sendNtfyNotification, isNtfyEventEnabled, buildNtfyClickUrl } = setupMockPlanningNtfyHelpers({
enabledEvent: true,
clickUrl: "http://localhost:4040/?project=proj-123",
});
const sessionId = await createSessionWithAgent(
getUniqueIp(),
"Build auth system",
TEST_ROOT_DIR,
undefined,
undefined,
undefined,
{
projectId: "proj-123",
ntfyConfig: {
enabled: true,
topic: "planning-topic",
dashboardHost: "http://localhost:4040/",
events: ["planning-awaiting-input"],
},
},
);
await vi.waitFor(() => {
expect(sendNtfyNotification).toHaveBeenCalledTimes(1);
});
await submitResponse(sessionId, { "q-1": "answer one" }, TEST_ROOT_DIR);
await flushAsyncWork();
expect(sendNtfyNotification).toHaveBeenCalledTimes(1);
await submitResponse(sessionId, { "q-1": "answer two" }, TEST_ROOT_DIR);
await vi.waitFor(() => {
expect(sendNtfyNotification).toHaveBeenCalledTimes(2);
});
expect(isNtfyEventEnabled).toHaveBeenCalledWith(["planning-awaiting-input"], "planning-awaiting-input");
expect(buildNtfyClickUrl).toHaveBeenCalledWith({
dashboardHost: "http://localhost:4040/",
projectId: "proj-123",
});
expect(sendNtfyNotification).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
topic: "planning-topic",
priority: "high",
clickUrl: "http://localhost:4040/?project=proj-123",
}),
);
});
it("suppresses planning awaiting-input notifications when event is disabled", async () => {
setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES });
const { sendNtfyNotification } = setupMockPlanningNtfyHelpers({ enabledEvent: false });
await createSessionWithAgent(
getUniqueIp(),
"Build auth system",
TEST_ROOT_DIR,
undefined,
undefined,
undefined,
{
projectId: "proj-123",
ntfyConfig: {
enabled: true,
topic: "planning-topic",
dashboardHost: "http://localhost:4040/",
events: ["failed"],
},
},
);
await flushAsyncWork();
expect(sendNtfyNotification).not.toHaveBeenCalled();
});
});
describe("submitResponse", () => {

View File

@@ -17,6 +17,7 @@ import type {
PlanningSummary,
PlanningResponse,
TaskStore,
NtfyNotificationEvent,
} from "@fusion/core";
import { resolvePrompt, type PromptOverrideMap } from "@fusion/core";
import type { SubtaskItem } from "./subtask-breakdown.js";
@@ -36,6 +37,30 @@ type AgentResult = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createFnAgent: any;
interface PlanningNtfyConfig {
enabled: boolean;
topic?: string;
dashboardHost?: string;
events?: NtfyNotificationEvent[];
ntfyBaseUrl?: string;
}
interface PlanningNtfyHelpers {
isNtfyEventEnabled: (events: NtfyNotificationEvent[] | undefined, event: NtfyNotificationEvent) => boolean;
buildNtfyClickUrl: (options: { dashboardHost?: string; projectId?: string; taskId?: string }) => string | undefined;
sendNtfyNotification: (input: {
ntfyBaseUrl?: string;
topic: string;
title: string;
message: string;
priority?: "low" | "default" | "high" | "urgent";
clickUrl?: string;
}) => Promise<void>;
}
let planningNtfyHelpers: PlanningNtfyHelpers | undefined;
let ntfyHelpersReady: Promise<void> | undefined;
/**
* Shared diagnostics helper for the planning module.
* Uses the shared ai-session-diagnostics helper for consistent scoped logging.
@@ -69,19 +94,24 @@ export function __setPlanningDiagnostics(_logger: unknown): void {
// Initialize the import (this runs in actual server, mocked in tests)
async function initEngine() {
if (!createFnAgent) {
try {
// Use dynamic import with variable to prevent static analysis
const engineModule = "@fusion/engine";
const engine = await import(/* @vite-ignore */ engineModule);
if (!createFnAgent) {
createFnAgent = engine.createFnAgent;
}
} catch {
// Allow failure in test environments - agent functionality will be stubbed
if (!createFnAgent) {
createFnAgent = undefined;
}
try {
// Use dynamic import with variable to prevent static analysis
const engineModule = "@fusion/engine";
const engine = await import(/* @vite-ignore */ engineModule);
if (!createFnAgent) {
createFnAgent = engine.createFnAgent;
}
if (!planningNtfyHelpers) {
planningNtfyHelpers = {
isNtfyEventEnabled: engine.isNtfyEventEnabled,
buildNtfyClickUrl: engine.buildNtfyClickUrl,
sendNtfyNotification: engine.sendNtfyNotification,
};
}
} catch {
// Allow failure in test environments - agent functionality will be stubbed
if (!createFnAgent) {
createFnAgent = undefined;
}
}
}
@@ -92,6 +122,11 @@ function ensureEngineReady() {
return engineReady;
}
async function ensureNtfyHelpersReady(): Promise<void> {
ntfyHelpersReady ??= initEngine();
await ntfyHelpersReady;
}
// ── Constants ───────────────────────────────────────────────────────────────
/** Planning system prompt for the AI agent */
@@ -170,6 +205,10 @@ interface Session {
id: string;
ip: string;
initialPlan: string;
projectId?: string;
ntfyConfig?: PlanningNtfyConfig;
/** Last planning question notified via ntfy, keyed as `${sessionId}:${questionId}` for dedupe across reconnect/replay. */
lastNotifiedQuestionKey?: string;
history: PlanningHistoryEntry[];
currentQuestion?: PlanningQuestion;
summary?: PlanningSummary;
@@ -260,7 +299,7 @@ function cleanupInMemorySession(sessionId: string): boolean {
}
/** Persist the current session state to SQLite (no-op if store not wired). */
function persistSession(session: Session, status: "generating" | "awaiting_input" | "complete" | "error", projectId?: string, error?: string): void {
function persistSession(session: Session, status: "generating" | "awaiting_input" | "complete" | "error", error?: string): void {
if (!_aiSessionStore) return;
const row: AiSessionRow = {
id: session.id,
@@ -273,7 +312,7 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
result: session.summary ? JSON.stringify(session.summary) : null,
thinkingOutput: session.thinkingOutput,
error: error ?? null,
projectId: projectId ?? null,
projectId: session.projectId ?? null,
createdAt: session.createdAt.toISOString(),
updatedAt: new Date().toISOString(),
lockedByTab: null,
@@ -308,21 +347,25 @@ function buildSessionFromRow(row: AiSessionRow): Session {
throw new Error("Invalid session timestamps");
}
const currentQuestion = row.currentQuestion
? (safeParseJson<PlanningQuestion | null>(row.currentQuestion, null, {
throwOnError: true,
fieldName: "currentQuestion",
}) ?? undefined)
: undefined;
return {
id: row.id,
ip: payload.ip ?? "",
initialPlan: payload.initialPlan ?? row.title,
projectId: row.projectId ?? undefined,
history: safeParseJson<PlanningHistoryEntry[]>(
row.conversationHistory,
[],
{ throwOnError: true, fieldName: "conversationHistory" },
),
currentQuestion: row.currentQuestion
? (safeParseJson<PlanningQuestion | null>(row.currentQuestion, null, {
throwOnError: true,
fieldName: "currentQuestion",
}) ?? undefined)
: undefined,
currentQuestion,
lastNotifiedQuestionKey: currentQuestion ? `${row.id}:${currentQuestion.id}` : undefined,
summary: row.result
? (safeParseJson<PlanningSummary | null>(row.result, null, {
throwOnError: true,
@@ -768,6 +811,7 @@ export async function createSessionWithAgent(
modelProvider?: string,
modelId?: string,
promptOverrides?: PromptOverrideMap,
options?: { projectId?: string; ntfyConfig?: PlanningNtfyConfig },
): Promise<string> {
// Check rate limit
if (!checkRateLimit(ip)) {
@@ -784,6 +828,16 @@ export async function createSessionWithAgent(
id: sessionId,
ip,
initialPlan,
projectId: options?.projectId,
ntfyConfig: options?.ntfyConfig
? {
enabled: options.ntfyConfig.enabled,
topic: options.ntfyConfig.topic,
dashboardHost: options.ntfyConfig.dashboardHost,
events: options.ntfyConfig.events ? [...options.ntfyConfig.events] : undefined,
ntfyBaseUrl: options.ntfyConfig.ntfyBaseUrl,
}
: undefined,
history: [],
thinkingOutput: "",
lastGeneratedThinking: "",
@@ -797,7 +851,7 @@ export async function createSessionWithAgent(
// Initialize AI agent in background - it will stream via planningStreamManager
initializeAgent(session, rootDir, modelProvider, modelId, promptOverrides).catch((err) => {
diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
persistSession(session, "error", undefined, err.message || "Failed to initialize AI agent");
persistSession(session, "error", err.message || "Failed to initialize AI agent");
planningStreamManager.broadcast(sessionId, {
type: "error",
data: err.message || "Failed to initialize AI agent",
@@ -828,7 +882,7 @@ async function initializeAgent(
diagnostics.errorFromException("Agent initialization error for session", err, { sessionId: session.id, operation: "initialize-agent" });
session.error = errorMessage;
session.updatedAt = new Date();
persistSession(session, "error", undefined, errorMessage);
persistSession(session, "error", errorMessage);
planningStreamManager.broadcast(session.id, {
type: "error",
data: errorMessage,
@@ -915,6 +969,53 @@ async function ensureSessionAgent(
await session.agent.session.prompt(contextMessage);
}
async function maybeNotifyPlanningAwaitingInput(session: Session, question: PlanningQuestion): Promise<void> {
const config = session.ntfyConfig;
if (!config?.enabled || !config.topic) {
return;
}
await ensureNtfyHelpersReady();
const eventEnabled = planningNtfyHelpers?.isNtfyEventEnabled
? planningNtfyHelpers.isNtfyEventEnabled(config.events, "planning-awaiting-input")
: (config.events ? config.events.includes("planning-awaiting-input") : true);
if (!eventEnabled) {
return;
}
const questionKey = `${session.id}:${question.id}`;
if (session.lastNotifiedQuestionKey === questionKey) {
return;
}
session.lastNotifiedQuestionKey = questionKey;
if (!planningNtfyHelpers) {
return;
}
try {
const clickUrl = planningNtfyHelpers.buildNtfyClickUrl({
dashboardHost: config.dashboardHost,
projectId: session.projectId,
});
await planningNtfyHelpers.sendNtfyNotification({
ntfyBaseUrl: config.ntfyBaseUrl,
topic: config.topic,
title: "Planning needs your input",
message: `Planning mode is waiting for input: ${question.question}`,
priority: "high",
clickUrl,
});
} catch (error) {
diagnostics.warn("Failed to deliver planning awaiting-input ntfy notification", {
sessionId: session.id,
questionId: question.id,
error: error instanceof Error ? error.message : String(error),
operation: "planning-notify-awaiting-input",
});
}
}
/** Max number of retry attempts when AI returns unparseable output */
const MAX_PARSE_RETRIES = 1;
@@ -1024,7 +1125,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
);
session.error = errorMsg;
session.updatedAt = new Date();
persistSession(session, "error", undefined, errorMsg);
persistSession(session, "error", errorMsg);
planningStreamManager.broadcast(session.id, {
type: "error",
data: errorMsg,
@@ -1038,6 +1139,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date();
persistSession(session, "awaiting_input");
void maybeNotifyPlanningAwaitingInput(session, parsed.data);
planningStreamManager.broadcast(session.id, {
type: "question",
data: parsed.data,
@@ -1059,7 +1161,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
diagnostics.errorFromException("Agent conversation error for session", err, { sessionId: session.id, operation: "conversation" });
session.error = errorMessage;
session.updatedAt = new Date();
persistSession(session, "error", undefined, errorMessage);
persistSession(session, "error", errorMessage);
planningStreamManager.broadcast(session.id, {
type: "error",
data: errorMessage,
@@ -1603,6 +1705,9 @@ export function __resetPlanningState(): void {
_aiSessionDeletedListener = undefined;
_aiSessionStore = undefined;
planningNtfyHelpers = undefined;
ntfyHelpersReady = undefined;
// Reset diagnostics sink to default
resetDiagnosticsSink();
}
@@ -1614,6 +1719,12 @@ export function __setCreateFnAgent(mock: typeof createFnAgent): void {
createFnAgent = mock;
}
/** Inject ntfy helper implementations (test-only). */
export function __setPlanningNtfyHelpers(mock: PlanningNtfyHelpers | undefined): void {
planningNtfyHelpers = mock;
ntfyHelpersReady = undefined;
}
// ── Custom Errors ───────────────────────────────────────────────────────────
export class RateLimitError extends Error {

View File

@@ -8752,7 +8752,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("planningModelId must be a string when provided");
}
const { store: scopedStore } = await getProjectContext(req);
const { store: scopedStore, projectId } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = scopedStore.getRootDir();
@@ -8782,6 +8782,15 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
resolvedPlanningProvider,
resolvedPlanningModelId,
settings.promptOverrides,
{
projectId,
ntfyConfig: {
enabled: settings.ntfyEnabled ?? false,
topic: settings.ntfyTopic,
dashboardHost: settings.ntfyDashboardHost,
events: settings.ntfyEvents,
},
},
);
res.status(201).json({ sessionId });
} catch (err: unknown) {

View File

@@ -38,7 +38,18 @@ export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
export { withRateLimitRetry } from "./rate-limit-retry.js";
export { PrMonitor, type PrComment, type TrackedPr, type OnNewCommentsCallback } from "./pr-monitor.js";
export { PrCommentHandler } from "./pr-comment-handler.js";
export { NtfyNotifier, type NtfyNotifierOptions } from "./notifier.js";
export {
NtfyNotifier,
DEFAULT_NTFY_EVENTS,
resolveNtfyEvents,
isNtfyEventEnabled,
buildNtfyClickUrl,
sendNtfyNotification,
type NtfyNotifierOptions,
type NtfyNotificationPriority,
type NtfyNotificationConfigInput,
type SendNtfyNotificationInput,
} from "./notifier.js";
export { CronRunner, type CronRunnerOptions, type AiPromptExecutor, createAiPromptExecutor } from "./cron-runner.js";
export { RoutineRunner, type RoutineRunnerOptions } from "./routine-runner.js";
export { RoutineScheduler, type RoutineSchedulerOptions } from "./routine-scheduler.js";

View File

@@ -1,7 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Task, Column, MergeResult, Settings } from "@fusion/core";
import { NtfyNotifier } from "./notifier.js";
import {
NtfyNotifier,
DEFAULT_NTFY_EVENTS,
buildNtfyClickUrl,
isNtfyEventEnabled,
resolveNtfyEvents,
} from "./notifier.js";
// Mock the logger
vi.mock("./logger.js", () => ({
@@ -56,6 +62,24 @@ class MockTaskStore extends EventEmitter<MockTaskStoreEvents> {
}
}
describe("Ntfy notifier helpers", () => {
it("includes planning-awaiting-input in default events", () => {
expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input");
expect(resolveNtfyEvents(undefined)).toContain("planning-awaiting-input");
});
it("checks planning-awaiting-input event enablement", () => {
expect(isNtfyEventEnabled(["planning-awaiting-input"], "planning-awaiting-input")).toBe(true);
expect(isNtfyEventEnabled(["failed"], "planning-awaiting-input")).toBe(false);
});
it("builds project dashboard root links without task id", () => {
expect(buildNtfyClickUrl({ dashboardHost: "http://localhost:4040/", projectId: "proj-1" })).toBe(
"http://localhost:4040/?project=proj-1",
);
});
});
describe("NtfyNotifier", () => {
let store: MockTaskStore;
let notifier: NtfyNotifier;
@@ -1334,7 +1358,7 @@ describe("NtfyNotifier", () => {
});
it("updates notifications when ntfyEvents changes at runtime", async () => {
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] });
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] });
notifier = new NtfyNotifier(store);
await notifier.start();
@@ -1351,7 +1375,7 @@ describe("NtfyNotifier", () => {
expect(fetchMock).toHaveBeenCalledTimes(1); // No new call for in-review
// Enable in-review again
store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] });
store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] });
store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-progress", "in-review");
await flushAsyncWork();

View File

@@ -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] };
}