feat(KB-026): add ntfy.sh push notification support
- Add ntfy settings (ntfyEnabled, ntfyTopic) to core types and config storage - Create NtfyNotifier service with event-based notifications for task completion/failure - Add Notifications section to SettingsModal for configuring ntfy topic - Wire up NtfyNotifier in engine to send notifications on task state changes - Include ntfy topic in task templates for per-task notification override - Add comprehensive tests for NtfyNotifier service
This commit is contained in:
@@ -2,7 +2,7 @@ import { exec } from "node:child_process";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { TaskStore } from "@kb/core";
|
||||
import { createServer } from "@kb/dashboard";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees } from "@kb/engine";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier } from "@kb/engine";
|
||||
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
@@ -19,6 +19,10 @@ export async function runDashboard(port: number, opts: { open?: boolean; paused?
|
||||
await store.init();
|
||||
await store.watch();
|
||||
|
||||
// ── NtfyNotifier: push notifications for task completion and failures ─
|
||||
const notifier = new NtfyNotifier(store);
|
||||
notifier.start();
|
||||
|
||||
// Set enginePaused if starting in paused mode
|
||||
if (opts.paused) {
|
||||
await store.updateSettings({ enginePaused: true });
|
||||
@@ -375,6 +379,7 @@ export async function runDashboard(port: number, opts: { open?: boolean; paused?
|
||||
process.on("SIGINT", () => {
|
||||
triage.stop();
|
||||
scheduler.stop();
|
||||
notifier.stop();
|
||||
if (mergeRetryTimer) clearTimeout(mergeRetryTimer);
|
||||
store.stopWatching();
|
||||
process.exit(0);
|
||||
@@ -384,6 +389,7 @@ export async function runDashboard(port: number, opts: { open?: boolean; paused?
|
||||
// Dev mode: simplified SIGINT handler (no engine components)
|
||||
if (opts.dev) {
|
||||
process.on("SIGINT", () => {
|
||||
notifier.stop();
|
||||
store.stopWatching();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
|
||||
import { join, sep } from "node:path";
|
||||
import { existsSync, watch, type FSWatcher } from "node:fs";
|
||||
import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs";
|
||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings } from "./types.js";
|
||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
||||
|
||||
@@ -1174,6 +1174,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
? task.dependencies.map((d) => `- **Task:** ${d}`).join("\n")
|
||||
: "- **None**";
|
||||
|
||||
// Get current settings to check for ntfy configuration
|
||||
const settings = this.getSettingsSync();
|
||||
const notificationsSection =
|
||||
settings.ntfyEnabled && settings.ntfyTopic
|
||||
? `\n## Notifications\n\nntfy topic: \`${settings.ntfyTopic}\`\n`
|
||||
: "";
|
||||
|
||||
const heading = task.title ? `${task.id}: ${task.title}` : task.id;
|
||||
return `# ${heading}
|
||||
|
||||
@@ -1209,6 +1216,23 @@ ${deps}
|
||||
|
||||
- [ ] All steps complete
|
||||
- [ ] All tests passing
|
||||
`;
|
||||
${notificationsSection}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version of getSettings for internal use.
|
||||
* Returns cached settings or default settings if not loaded.
|
||||
*/
|
||||
private getSettingsSync(): Settings {
|
||||
// Since we can't easily make generateSpecifiedPrompt async,
|
||||
// we read settings synchronously from the file.
|
||||
// The settings file is read during init and on each update,
|
||||
// so this should be reasonably up-to-date for prompt generation.
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(this.configPath, "utf-8"));
|
||||
return { ...DEFAULT_SETTINGS, ...config.settings };
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +209,12 @@ export interface Settings {
|
||||
* remain in triage with status "awaiting-approval" until a user approves
|
||||
* or rejects the plan. Default: false. */
|
||||
requirePlanApproval?: boolean;
|
||||
/** ntfy.sh topic name for push notifications. When set along with ntfyEnabled,
|
||||
* notifications are sent to https://ntfy.sh/{topic} when tasks complete or fail. */
|
||||
ntfyTopic?: string;
|
||||
/** When true, enables ntfy.sh push notifications for task completion and failures.
|
||||
* Requires ntfyTopic to be set. Default: false. */
|
||||
ntfyEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -229,6 +235,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
requirePlanApproval: false,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
};
|
||||
|
||||
export interface BoardConfig {
|
||||
|
||||
@@ -44,7 +44,8 @@ The Git Manager provides comprehensive repository visualization and management d
|
||||
- View operation results and error states
|
||||
|
||||
### Configuration
|
||||
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences
|
||||
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences, and notifications
|
||||
- **Notifications**: ntfy.sh integration for push notifications when tasks complete or fail
|
||||
- **Authentication**: OAuth provider management for AI model access
|
||||
- **Pause Controls**: Soft pause (stop new work) and hard stop (kill all agents)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const SETTINGS_SECTIONS = [
|
||||
{ id: "worktrees", label: "Worktrees" },
|
||||
{ id: "commands", label: "Commands" },
|
||||
{ id: "merge", label: "Merge" },
|
||||
{ id: "notifications", label: "Notifications" },
|
||||
{ id: "authentication", label: "Authentication" },
|
||||
] as const;
|
||||
|
||||
@@ -488,6 +489,52 @@ export function SettingsModal({ onClose, addToast, initialSection }: SettingsMod
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "notifications":
|
||||
return (
|
||||
<>
|
||||
<h4 className="settings-section-heading">Notifications</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="ntfyEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="ntfyEnabled"
|
||||
type="checkbox"
|
||||
checked={form.ntfyEnabled || false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, ntfyEnabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Enable ntfy.sh notifications
|
||||
</label>
|
||||
<small>Receive push notifications when tasks complete or fail via ntfy.sh</small>
|
||||
</div>
|
||||
{form.ntfyEnabled && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="ntfyTopic">ntfy Topic</label>
|
||||
<input
|
||||
id="ntfyTopic"
|
||||
type="text"
|
||||
placeholder="my-topic-name"
|
||||
value={form.ntfyTopic || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, ntfyTopic: val || undefined }));
|
||||
}}
|
||||
/>
|
||||
<small>
|
||||
Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "}
|
||||
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer">
|
||||
Learn more about ntfy.sh
|
||||
</a>
|
||||
</small>
|
||||
{form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && (
|
||||
<small className="field-error">
|
||||
Topic must be 1–64 alphanumeric, hyphen, or underscore characters
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "authentication":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -15,6 +15,8 @@ const defaultSettings: Settings = {
|
||||
buildCommand: "",
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
};
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -681,17 +683,17 @@ describe("SettingsModal", () => {
|
||||
expect(layout!.querySelector(".settings-content")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has .settings-sidebar with 7 .settings-nav-item buttons for all sections", async () => {
|
||||
it("has .settings-sidebar with 8 .settings-nav-item buttons for all sections", async () => {
|
||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const sidebar = container.querySelector(".settings-sidebar");
|
||||
expect(sidebar).toBeTruthy();
|
||||
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
|
||||
expect(navItems.length).toBe(7);
|
||||
expect(navItems.length).toBe(8);
|
||||
|
||||
const labels = Array.from(navItems).map((el) => el.textContent);
|
||||
expect(labels).toEqual(["General", "Model", "Scheduling", "Worktrees", "Commands", "Merge", "Authentication"]);
|
||||
expect(labels).toEqual(["General", "Model", "Scheduling", "Worktrees", "Commands", "Merge", "Notifications", "Authentication"]);
|
||||
});
|
||||
|
||||
it("has .settings-content as sibling of .settings-sidebar", async () => {
|
||||
@@ -744,4 +746,136 @@ describe("SettingsModal", () => {
|
||||
expect(row.querySelector("button")).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Notifications section tests ---
|
||||
|
||||
it("shows Notifications in sidebar", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getAllByText("Notifications").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows ntfy enable checkbox in Notifications section", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||
expect(checkbox).toBeTruthy();
|
||||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||||
});
|
||||
|
||||
it("ntfy topic input is hidden when ntfy is disabled", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
expect(screen.queryByLabelText("ntfy Topic")).toBeNull();
|
||||
});
|
||||
|
||||
it("ntfy topic input is visible when ntfy is enabled", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggling ntfyEnabled checkbox sends true in save payload", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.ntfyEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("ntfy topic field saves correctly when set", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "my-topic" } });
|
||||
expect(input.value).toBe("my-topic");
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.ntfyTopic).toBe("my-topic");
|
||||
});
|
||||
|
||||
it("ntfy topic field submits undefined when empty", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "existing-topic",
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.ntfyTopic).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ntfy topic shows validation error for invalid input", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "invalid topic with spaces!" } });
|
||||
|
||||
expect(screen.getByText("Topic must be 1–64 alphanumeric, hyphen, or underscore characters")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ntfyEnabled defaults to false when setting is undefined", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("ntfyEnabled shows correct state when enabled in settings", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,4 +10,4 @@ export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "./wor
|
||||
export { createLogger, type Logger } from "./logger.js";
|
||||
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.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";
|
||||
|
||||
467
packages/engine/src/notifier.test.ts
Normal file
467
packages/engine/src/notifier.test.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Task, Column, MergeResult, Settings } from "@kb/core";
|
||||
import { NtfyNotifier } from "./notifier.js";
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("./logger.js", () => ({
|
||||
schedulerLog: { log: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
interface MockTaskStoreEvents {
|
||||
"task:moved": [{ task: Task; from: Column; to: Column }];
|
||||
"task:updated": [Task];
|
||||
"task:merged": [MergeResult];
|
||||
"settings:updated": [{ settings: Settings; previous: Settings }];
|
||||
}
|
||||
|
||||
class MockTaskStore extends EventEmitter<MockTaskStoreEvents> {
|
||||
private settings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
};
|
||||
|
||||
getSettings(): Settings {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
setSettings(settings: Partial<Settings>): void {
|
||||
const previous = { ...this.settings };
|
||||
this.settings = { ...this.settings, ...settings };
|
||||
this.emit("settings:updated", { settings: this.settings, previous });
|
||||
}
|
||||
|
||||
// Helper to trigger events
|
||||
triggerTaskMoved(task: Task, from: Column, to: Column): void {
|
||||
this.emit("task:moved", { task, from, to });
|
||||
}
|
||||
|
||||
triggerTaskUpdated(task: Task): void {
|
||||
this.emit("task:updated", task);
|
||||
}
|
||||
|
||||
triggerTaskMerged(result: MergeResult): void {
|
||||
this.emit("task:merged", result);
|
||||
}
|
||||
}
|
||||
|
||||
describe("NtfyNotifier", () => {
|
||||
let store: MockTaskStore;
|
||||
let notifier: NtfyNotifier;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = new MockTaskStore();
|
||||
fetchMock = vi.fn();
|
||||
global.fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (notifier) {
|
||||
notifier.stop();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createTask = (id: string, title?: string, status?: string): Task => ({
|
||||
id,
|
||||
title,
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
status,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
log: [],
|
||||
});
|
||||
|
||||
describe("when disabled", () => {
|
||||
it("does not send any notifications when ntfyEnabled is false", async () => {
|
||||
store.setSettings({ ntfyEnabled: false, ntfyTopic: "my-topic" });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||
|
||||
// Wait for any async operations
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send notifications when ntfyTopic is not set", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: undefined });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when enabled", () => {
|
||||
beforeEach(() => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("sends notification when task moves to in-review", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Title": "Task KB-001 completed",
|
||||
"Priority": "default",
|
||||
}),
|
||||
body: 'Task "Test Task" is ready for review',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends notification when task moves to done", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-review", "done");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Title": "Task KB-001 merged",
|
||||
"Priority": "default",
|
||||
}),
|
||||
body: 'Task "Test Task" has been merged to main',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends high priority notification when task fails", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const failedTask = createTask("KB-001", "Test Task", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Title": "Task KB-001 failed",
|
||||
"Priority": "high",
|
||||
}),
|
||||
body: 'Task "Test Task" has failed and needs attention',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends notification when task is merged", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("KB-001", "Test Task"),
|
||||
branch: "kb/kb-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Title": "Task KB-001 merged",
|
||||
"Priority": "default",
|
||||
}),
|
||||
body: 'Task "Test Task" has been merged to main',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send notification for failed merges", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("KB-001", "Test Task"),
|
||||
branch: "kb/kb-001",
|
||||
merged: false,
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: false,
|
||||
error: "Merge conflict",
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses task ID when title is not available", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("KB-001"), "in-progress", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
body: 'Task "KB-001" is ready for review',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime reconfiguration", () => {
|
||||
it("starts sending notifications when enabled at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially disabled
|
||||
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
// Enable at runtime
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
store.setSettings({ ntfyEnabled: true });
|
||||
|
||||
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops sending notifications when disabled at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially enabled
|
||||
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Disable at runtime
|
||||
store.setSettings({ ntfyEnabled: false });
|
||||
|
||||
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // No new calls
|
||||
});
|
||||
|
||||
it("uses updated topic when changed at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "old-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/old-topic", expect.any(Object));
|
||||
|
||||
// Change topic
|
||||
store.setSettings({ ntfyTopic: "new-topic" });
|
||||
|
||||
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/new-topic", expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("catches and logs fetch errors without throwing", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Should not throw
|
||||
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles HTTP error responses without throwing", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Should not throw
|
||||
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("debouncing", () => {
|
||||
beforeEach(() => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("prevents duplicate notifications within debounce window", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("KB-001", "Test Task");
|
||||
|
||||
// Rapid transitions
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task, "in-review", "done");
|
||||
store.triggerTaskMoved(task, "done", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Should only send one notification due to debouncing
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows notifications after debounce window", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("KB-001", "Test Task");
|
||||
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Wait for debounce window (5 seconds) - use fake timers or access internal state
|
||||
// For this test, we'll create a new task to verify separate tasks aren't debounced together
|
||||
const task2 = createTask("KB-002", "Test Task 2");
|
||||
store.triggerTaskMoved(task2, "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Different task ID should get its own notification
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom base URL", () => {
|
||||
it("uses custom ntfy base URL when provided", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store, { ntfyBaseUrl: "https://my-ntfy.example.com" });
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://my-ntfy.example.com/test-topic",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop()", () => {
|
||||
it("stops listening to events after stop() is called", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
notifier.stop();
|
||||
|
||||
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Should not increase after stop
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("does not notify on task:moved to columns other than in-review or done", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Move to todo - should not notify
|
||||
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "triage", "todo");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Move to in-progress - should not notify
|
||||
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "todo", "in-progress");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not notify on task:updated when status is not failed", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("KB-001", "Test Task", "in-progress");
|
||||
store.triggerTaskUpdated(task);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles empty topic gracefully", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Empty topic should be treated as no topic
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
238
packages/engine/src/notifier.ts
Normal file
238
packages/engine/src/notifier.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { TaskStore, Task, Column, Settings, MergeResult } from "@kb/core";
|
||||
import { schedulerLog } from "./logger.js";
|
||||
|
||||
export interface NtfyNotifierOptions {
|
||||
/** Base URL for ntfy.sh. Default: https://ntfy.sh */
|
||||
ntfyBaseUrl?: string;
|
||||
}
|
||||
|
||||
interface NtfyConfig {
|
||||
enabled: boolean;
|
||||
topic: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 for rapid column transitions
|
||||
* - Configurable notification events (hardcoded defaults)
|
||||
*/
|
||||
export class NtfyNotifier {
|
||||
private config: NtfyConfig = { enabled: false, topic: undefined };
|
||||
private ntfyBaseUrl: string;
|
||||
/** Tracks last notification time per task to prevent duplicates */
|
||||
private lastNotificationTime: Map<string, number> = new Map();
|
||||
/** Minimum interval between notifications for the same task (ms) */
|
||||
private debounceMs = 5000;
|
||||
/** AbortController for in-flight requests during shutdown */
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
constructor(
|
||||
private store: TaskStore,
|
||||
options: NtfyNotifierOptions = {},
|
||||
) {
|
||||
this.ntfyBaseUrl = options.ntfyBaseUrl ?? "https://ntfy.sh";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
this.store.off("task:moved", this.handleTaskMoved);
|
||||
this.store.off("task:updated", this.handleTaskUpdated);
|
||||
this.store.off("task:merged", this.handleTaskMerged);
|
||||
this.store.off("settings:updated", this.handleSettingsUpdated);
|
||||
|
||||
// Abort any in-flight requests
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
|
||||
schedulerLog.log("NtfyNotifier stopped");
|
||||
}
|
||||
|
||||
private handleTaskMoved = (data: { task: Task; from: Column; to: Column }): void => {
|
||||
if (!this.config.enabled || !this.config.topic) return;
|
||||
|
||||
const { task, to } = data;
|
||||
|
||||
// Notify when task moves to in-review (completed work, ready for review)
|
||||
if (to === "in-review") {
|
||||
this.maybeNotify(task.id, () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${task.id} completed`,
|
||||
`Task "${task.title ?? task.id}" is ready for review`,
|
||||
"default",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Notify when task moves to done (merged to main)
|
||||
if (to === "done") {
|
||||
this.maybeNotify(task.id, () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${task.id} merged`,
|
||||
`Task "${task.title ?? task.id}" has been merged to main`,
|
||||
"default",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private handleTaskUpdated = (task: Task): void => {
|
||||
if (!this.config.enabled || !this.config.topic) return;
|
||||
|
||||
// Notify when task fails
|
||||
if (task.status === "failed") {
|
||||
this.maybeNotify(task.id, () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${task.id} failed`,
|
||||
`Task "${task.title ?? task.id}" has failed and needs attention`,
|
||||
"high",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private handleTaskMerged = (result: MergeResult): void => {
|
||||
if (!this.config.enabled || !this.config.topic) return;
|
||||
|
||||
// Only notify on successful merges
|
||||
if (result.merged) {
|
||||
this.maybeNotify(result.task.id, () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${result.task.id} merged`,
|
||||
`Task "${result.task.title ?? result.task.id}" has been merged to main`,
|
||||
"default",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
const wasEnabled = this.config.enabled;
|
||||
this.loadConfig(settings);
|
||||
|
||||
if (this.config.enabled && !wasEnabled) {
|
||||
schedulerLog.log("NtfyNotifier enabled");
|
||||
} else if (!this.config.enabled && wasEnabled) {
|
||||
schedulerLog.log("NtfyNotifier disabled");
|
||||
} else if (this.config.topic !== previous.ntfyTopic) {
|
||||
schedulerLog.log("NtfyNotifier topic updated");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private loadConfig(settings: Settings): void {
|
||||
this.config = {
|
||||
enabled: settings.ntfyEnabled ?? false,
|
||||
topic: settings.ntfyTopic,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification if enough time has passed since last notification for this task.
|
||||
* This prevents duplicate notifications during rapid column transitions.
|
||||
*/
|
||||
private maybeNotify(taskId: string, notifyFn: () => Promise<void>): void {
|
||||
const now = Date.now();
|
||||
const lastTime = this.lastNotificationTime.get(taskId);
|
||||
|
||||
if (lastTime && now - lastTime < this.debounceMs) {
|
||||
// Too soon, skip this notification
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastNotificationTime.set(taskId, now);
|
||||
notifyFn().catch(() => {
|
||||
// Errors are logged in sendNotification, just need to catch here
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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",
|
||||
): Promise<void> {
|
||||
const url = `${this.ntfyBaseUrl}/${topic}`;
|
||||
const signal = this.abortController?.signal;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Title": title,
|
||||
"Priority": priority,
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user