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

- Unify notification service ownership across the engine: `Notifier` now operates on `ProjectEngine` rather than its own `Core` reference, resolving stale-closure issues during engine lifecycle
- Add regression tests in `notifier.test.ts` covering merge dedupe wiring and ownership initialization
- Expand `project-engine.test.ts` to cover ownership state transitions and `ProjectEngine`-notifier integration
- Fix TypeScript regressions in `ModelOnboardingModal.tsx` and `SettingsModal.tsx` introduced by custom provider types
- Document the notification ownership model in `docs/architecture.md`

Commits merged:
- feat(FN-3003): complete Step 4 — document notification ownership model
- fix(FN-3003): resolve dashboard custom provider typecheck regressions
- test(FN-3003): complete Step 2 — add merge dedupe wiring regressions
- feat(FN-3003): complete Step 1 — unify notification service ownership

Files changed:
docs/architecture.md                               |  2 +
 .../app/components/ModelOnboardingModal.tsx        | 13 +++++-
 .../dashboard/app/components/SettingsModal.tsx     | 17 +++++--
 packages/engine/src/__tests__/notifier.test.ts     | 33 ++++++++++++++
 .../engine/src/__tests__/project-engine.test.ts    | 53 +++++++++++++++++++---
 packages/engine/src/notifier.ts                    |  3 +-
 packages/engine/src/project-engine.ts              | 12 +++--
 7 files changed, 116 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-3003
This commit is contained in:
Fusion
2026-04-29 21:48:13 -07:00
committed by gsxdsm
parent 64b5f677ff
commit 5e58ab91cf
9 changed files with 118 additions and 39 deletions

View File

@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectEngine } from "../project-engine.js";
import { runtimeLog } from "../logger.js";
import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js";
import { NtfyNotifier } from "../notifier.js";
import { NotificationService } from "../notification/index.js";
const mocks = vi.hoisted(() => ({
syncInsightExtractionAutomation: vi.fn(),
@@ -16,6 +18,11 @@ const mocks = vi.hoisted(() => ({
aiMergeTask: vi.fn(),
execFile: vi.fn(),
currentStore: null as Record<string, unknown> | null,
notifierStart: vi.fn(async () => undefined),
notifierStop: vi.fn(),
notifierNotifyGridlock: vi.fn(),
notificationServiceStart: vi.fn(async () => undefined),
notificationServiceStop: vi.fn(),
}));
vi.mock("@fusion/core", async () => {
@@ -69,16 +76,16 @@ vi.mock("../pr-comment-handler.js", () => ({
vi.mock("../notifier.js", () => ({
NtfyNotifier: vi.fn().mockImplementation(() => ({
start: vi.fn(async () => undefined),
stop: vi.fn(),
notifyGridlock: vi.fn(),
start: mocks.notifierStart,
stop: mocks.notifierStop,
notifyGridlock: mocks.notifierNotifyGridlock,
})),
}));
vi.mock("../notification/index.js", () => ({
NotificationService: vi.fn().mockImplementation(() => ({
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn(),
start: mocks.notificationServiceStart,
stop: mocks.notificationServiceStop,
})),
}));
@@ -209,7 +216,7 @@ const baseSettings: Record<string, unknown> = {
remoteAccess: baseRemoteAccess,
};
function createEngine() {
function createEngine(options?: ConstructorParameters<typeof ProjectEngine>[2]) {
return new ProjectEngine(
{
projectId: "proj_test",
@@ -219,11 +226,17 @@ function createEngine() {
maxWorktrees: 2,
},
{} as never,
{ skipNotifier: true },
{ skipNotifier: true, ...options },
);
}
beforeEach(() => {
mocks.notifierStart.mockClear();
mocks.notifierStop.mockClear();
mocks.notifierNotifyGridlock.mockClear();
mocks.notificationServiceStart.mockClear();
mocks.notificationServiceStop.mockClear();
mocks.execFile.mockImplementation((
_file: string,
_args: string[],
@@ -243,6 +256,32 @@ beforeEach(() => {
});
});
describe("ProjectEngine notification ownership wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
const mockStore = createMockStore(baseSettings);
mocks.currentStore = mockStore.store;
});
it("constructs NtfyNotifier with the same NotificationService instance and starts canonical listeners once", async () => {
const engine = createEngine({ skipNotifier: false, projectId: "proj_for_notifier" });
await engine.start();
expect(NotificationService).toHaveBeenCalledTimes(1);
expect(NtfyNotifier).toHaveBeenCalledTimes(1);
const notifierCtorArgs = vi.mocked(NtfyNotifier).mock.calls[0];
expect(notifierCtorArgs?.[2]).toBe(vi.mocked(NotificationService).mock.results[0]?.value);
expect(mocks.notificationServiceStart).toHaveBeenCalledTimes(1);
expect(mocks.notifierStart).toHaveBeenCalledTimes(1);
await engine.stop();
expect(mocks.notificationServiceStop).toHaveBeenCalledTimes(1);
expect(mocks.notifierStop).toHaveBeenCalledTimes(1);
});
});
describe("ProjectEngine auto-summarize wiring", () => {
beforeEach(() => {
vi.clearAllMocks();