feat(FN-2340): merge fusion/fn-2340
This commit is contained in:
5
.changeset/add-planning-awaiting-input-ntfy-event.md
Normal file
5
.changeset/add-planning-awaiting-input-ntfy-event.md
Normal 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.
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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