feat(FN-5389): add resume event instrumentation with SSE, hooks, and diagno

FN-5389 adds dashboard resume event instrumentation: a `resumeInstrumentation` utility captures SSE resume signals, wired through `useChat`, `useChatRooms`, and `useTasks` hooks, with remount markers in `Board` and `ChatView`; diagnostics routes expose resume events for observability, documented in

Fusion-Task-Id: FN-5389
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 19:55:46 -07:00
committed by gsxdsm
parent 12582414da
commit 1913cb59fe
16 changed files with 885 additions and 4 deletions

View File

@@ -75,3 +75,19 @@ Direct-report stale decisions in `HeartbeatMonitor.buildReportsHealthSection()`
- Fail-soft diagnostic: `[triage] <taskId>: broad-scope heuristic failed open: <message>` when the helper throws; the task still proceeds to `todo`. - Fail-soft diagnostic: `[triage] <taskId>: broad-scope heuristic failed open: <message>` when the helper throws; the task still proceeds to `todo`.
- Audit event: `task:broad-scope-flagged-at-triage` with `{ score, reasons, signals, thresholds, version }`. - Audit event: `task:broad-scope-flagged-at-triage` with `{ score, reasons, signals, thresholds, version }`.
- Task log side effect: `Broad-scope triage flag` advising operators to decompose via `fn_task_create` or set `breakIntoSubtasks=true` before execution. - Task log side effect: `Broad-scope triage flag` advising operators to decompose via `fn_task_create` or set `breakIntoSubtasks=true` before execution.
## Resume instrumentation (FN-5389, Phase 1)
Dashboard Phase 1 resume instrumentation adds observation-only client/server traces for refetch/reconnect attribution. It does not change visibility/pageshow/SSE behavior; FN-5392 consumes this data for fixes.
- Client event shape (`ResumeEvent`): `{ ts, view, trigger, projectId?, gapMs?, replayAttempted, replayFromEventId?, lastEventId?, sseChannel?, reason?, detail? }`.
- Trigger taxonomy: `visibility`, `pageshow`, `sse-error`, `sse-reconnect`, `sse-open`, `remount`, `route-active`, `route-inactive`, `project-context-change`.
- Sources:
- `sse-bus` (`pageshow`, visible `visibilitychange`, `openChannel`, `forceReconnect`, EventSource `error`)
- Hooks: `useTasks` (`visibility`, `sse-reconnect`), `useChatRooms` (`sse-reconnect`), `useChat` (`sse-open`, `project-context-change`)
- Components: `Board` and `ChatView` mount/unmount route markers (`remount` / `route-active` / `route-inactive`)
- Access paths:
- Client ring (500): `window.__fusionDebug.resumeInstrumentation.get()` / `.clear()`
- Server ring (5000, in-memory): `GET /api/diagnostics/resume-events?limit=&since=&view=` returns `{ events, droppedSinceLastRead }`
- Client batching: POST `/api/diagnostics/resume-events` in idle batches (`<=25` per POST).
- Disable knob: `window.__fusionDebug.resumeInstrumentation.setEnabled(false)`.

View File

@@ -6,6 +6,7 @@ import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { fetchWorkflowSteps, type ModelInfo } from "../api"; import { fetchWorkflowSteps, type ModelInfo } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout"; import { useBlockerFanout } from "../hooks/useBlockerFanout";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
interface BoardProps { interface BoardProps {
tasks: Task[]; tasks: Task[];
@@ -69,6 +70,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
} }
const EMPTY_WORKFLOW_STEP_NAME_LOOKUP: ReadonlyMap<string, string> = new Map(); const EMPTY_WORKFLOW_STEP_NAME_LOOKUP: ReadonlyMap<string, string> = new Map();
let boardWasPreviouslyInactive = false;
function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next: ReadonlyMap<string, string>): boolean { function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next: ReadonlyMap<string, string>): boolean {
if (previous.size !== next.size) return false; if (previous.size !== next.size) return false;
@@ -97,6 +99,26 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
archived: [], archived: [],
}); });
useEffect(() => {
recordResumeEvent({
view: "Board",
trigger: boardWasPreviouslyInactive ? "route-active" : "remount",
projectId,
replayAttempted: false,
});
boardWasPreviouslyInactive = false;
return () => {
boardWasPreviouslyInactive = true;
recordResumeEvent({
view: "Board",
trigger: "route-inactive",
projectId,
replayAttempted: false,
});
};
}, [projectId]);
const handleToggleArchivedCollapse = useCallback(() => { const handleToggleArchivedCollapse = useCallback(() => {
setArchivedCollapsed((current) => { setArchivedCollapsed((current) => {
const next = !current; const next = !current;

View File

@@ -45,6 +45,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { matchesAgentMentionFilter } from "./mentionMatching"; import { matchesAgentMentionFilter } from "./mentionMatching";
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
export interface ChatViewProps { export interface ChatViewProps {
projectId?: string; projectId?: string;
@@ -55,6 +56,7 @@ export interface ChatViewProps {
// Keep a generous cap so pasted multi-paragraph text stays visible while // Keep a generous cap so pasted multi-paragraph text stays visible while
// still preventing the composer from overtaking the message pane on short viewports. // still preventing the composer from overtaking the message pane on short viewports.
const CHAT_INPUT_MAX_HEIGHT_PX = 640; const CHAT_INPUT_MAX_HEIGHT_PX = 640;
let chatViewWasPreviouslyInactive = false;
export function clampChatInputHeight(scrollHeight: number): number { export function clampChatInputHeight(scrollHeight: number): number {
// Floor matches QuickChat (clampQuickChatInputHeight) and the CSS min-height, // Floor matches QuickChat (clampQuickChatInputHeight) and the CSS min-height,
@@ -889,6 +891,26 @@ const ChatMessageItem = memo(function ChatMessageItem({
}); });
export function ChatView({ projectId, addToast, experimentalFeatures }: ChatViewProps) { export function ChatView({ projectId, addToast, experimentalFeatures }: ChatViewProps) {
useEffect(() => {
recordResumeEvent({
view: "ChatView",
trigger: chatViewWasPreviouslyInactive ? "route-active" : "remount",
projectId,
replayAttempted: false,
});
chatViewWasPreviouslyInactive = false;
return () => {
chatViewWasPreviouslyInactive = true;
recordResumeEvent({
view: "ChatView",
trigger: "route-inactive",
projectId,
replayAttempted: false,
});
};
}, [projectId]);
const { const {
activeSession, activeSession,
sessionsLoading, sessionsLoading,

View File

@@ -37,7 +37,7 @@ describe("TaskDetailModal", () => {
expectBaseRule(css, ".detail-timestamp-item", "display: inline-flex;"); expectBaseRule(css, ".detail-timestamp-item", "display: inline-flex;");
expectBaseRule(css, ".detail-timestamp-separator", "color: var(--text-dim);"); expectBaseRule(css, ".detail-timestamp-separator", "color: var(--text-dim);");
expect(css).toMatch(/@media \(max-width: 768px\)\s*\{\s*\.detail-provenance\s*\{[^}]*\}\s*\.detail-timestamps\s*\{[^}]*align-items:\s*center;[^}]*flex-wrap:\s*nowrap;/); expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.detail-timestamps\s*\{[^}]*align-items:\s*center;[^}]*flex-wrap:\s*nowrap;/);
expect(css).not.toMatch(/@media \(max-width: 768px\)\s*\{[\s\S]*?\.detail-timestamps\s*\{[^}]*flex-direction:\s*column;/); expect(css).not.toMatch(/@media \(max-width: 768px\)\s*\{[\s\S]*?\.detail-timestamps\s*\{[^}]*flex-direction:\s*column;/);
expect(css).not.toMatch(/@media \(max-width: 768px\)\s*\{[\s\S]*?\.detail-timestamp-separator\s*\{[^}]*display:\s*none;/); expect(css).not.toMatch(/@media \(max-width: 768px\)\s*\{[\s\S]*?\.detail-timestamp-separator\s*\{[^}]*display:\s*none;/);
}); });

View File

@@ -0,0 +1,60 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const recordResumeEvent = vi.fn();
const subscribeCalls: Array<{ onReconnect?: () => void }> = [];
vi.mock("../../utils/resumeInstrumentation", () => ({
recordResumeEvent,
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: (_url: string, handlers: { onReconnect?: () => void }) => {
subscribeCalls.push({ onReconnect: handlers.onReconnect });
return () => {};
},
}));
vi.mock("../../api", () => ({
fetchChatRooms: vi.fn().mockResolvedValue({ rooms: [] }),
createChatRoom: vi.fn(),
fetchChatRoomMembers: vi.fn().mockResolvedValue({ members: [] }),
fetchChatRoomMessages: vi.fn().mockResolvedValue({ messages: [] }),
deleteChatRoom: vi.fn(),
postChatRoomMessage: vi.fn(),
uploadChatRoomAttachment: vi.fn(),
clearChatRoomMessages: vi.fn(),
}));
vi.mock("../../utils/projectStorage", () => ({
getScopedItem: vi.fn(() => null),
setScopedItem: vi.fn(),
removeScopedItem: vi.fn(),
}));
describe("useChatRooms resume instrumentation", () => {
beforeEach(() => {
subscribeCalls.length = 0;
recordResumeEvent.mockReset();
});
it("records sse-reconnect trigger on reconnect callback", async () => {
const { useChatRooms } = await import("../useChatRooms");
renderHook(() => useChatRooms("proj-1"));
await waitFor(() => {
expect(subscribeCalls[0]?.onReconnect).toBeTypeOf("function");
});
act(() => {
subscribeCalls[0]?.onReconnect?.();
});
expect(recordResumeEvent).toHaveBeenCalledWith(expect.objectContaining({
view: "useChatRooms",
trigger: "sse-reconnect",
projectId: "proj-1",
replayAttempted: false,
}));
});
});

View File

@@ -0,0 +1,87 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearTraces, getTraces } from "../../utils/dashboardTraceBuffer";
const recordResumeEvent = vi.fn();
const subscribeCalls: Array<{ onReconnect?: () => void }> = [];
vi.mock("../../utils/resumeInstrumentation", () => ({
recordResumeEvent,
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: (_url: string, handlers: { onReconnect?: () => void }) => {
subscribeCalls.push({ onReconnect: handlers.onReconnect });
return () => {};
},
}));
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
fetchTasks: vi.fn().mockResolvedValue([]),
});
});
describe("useTasks resume instrumentation", () => {
beforeEach(() => {
clearTraces();
subscribeCalls.length = 0;
recordResumeEvent.mockReset();
});
it("records visibility trigger and preserves visibility-context-version-changed trace", async () => {
const { useTasks } = await import("../useTasks");
const { rerender } = renderHook(
({ projectId }: { projectId: string }) => useTasks({ projectId }),
{ initialProps: { projectId: "proj-1" } },
);
await waitFor(() => {
expect(subscribeCalls.length).toBeGreaterThan(0);
});
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
await act(async () => {
rerender({ projectId: "proj-2" });
});
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(recordResumeEvent).toHaveBeenCalledWith(expect.objectContaining({
view: "useTasks",
trigger: "visibility",
projectId: "proj-2",
reason: "context-version-changed",
}));
expect(getTraces().some((entry) => entry.source === "useTasks" && entry.event === "visibility-context-version-changed")).toBe(true);
});
it("records sse-reconnect trigger on reconnect callback", async () => {
const { useTasks } = await import("../useTasks");
renderHook(() => useTasks({ projectId: "proj-1" }));
await waitFor(() => {
expect(subscribeCalls[0]?.onReconnect).toBeTypeOf("function");
});
act(() => {
subscribeCalls[0]?.onReconnect?.();
});
expect(recordResumeEvent).toHaveBeenCalledWith(expect.objectContaining({
view: "useTasks",
trigger: "sse-reconnect",
projectId: "proj-1",
replayAttempted: false,
}));
});
});

View File

@@ -14,6 +14,7 @@ import {
} from "../api"; } from "../api";
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import { getScopedItem, setScopedItem, removeScopedItem } from "../utils/projectStorage"; import { getScopedItem, setScopedItem, removeScopedItem } from "../utils/projectStorage";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import type { Agent, ChatInFlightGenerationState, ChatMessage } from "@fusion/core"; import type { Agent, ChatInFlightGenerationState, ChatMessage } from "@fusion/core";
const ACTIVE_SESSION_STORAGE_KEY = "kb-chat-active-session"; const ACTIVE_SESSION_STORAGE_KEY = "kb-chat-active-session";
@@ -323,6 +324,13 @@ export function useChat(
// Detect project changes and invalidate SSE context // Detect project changes and invalidate SSE context
if (previousProjectIdRef.current !== projectId) { if (previousProjectIdRef.current !== projectId) {
recordResumeEvent({
view: "useChat",
trigger: "project-context-change",
projectId,
replayAttempted: false,
detail: { previousProjectId: previousProjectIdRef.current ?? null },
});
previousProjectIdRef.current = projectId; previousProjectIdRef.current = projectId;
projectContextVersionRef.current++; projectContextVersionRef.current++;
} }
@@ -555,6 +563,14 @@ export function useChat(
}, },
}); });
recordResumeEvent({
view: "useChat",
trigger: "sse-open",
projectId,
replayAttempted: typeof inFlightGeneration?.replayFromEventId === "number",
replayFromEventId: inFlightGeneration?.replayFromEventId ?? null,
lastEventId: inFlightGeneration?.replayFromEventId ?? null,
});
const stream = attachChatStream(sessionId, handlers, projectId, { const stream = attachChatStream(sessionId, handlers, projectId, {
...(typeof inFlightGeneration?.replayFromEventId === "number" ...(typeof inFlightGeneration?.replayFromEventId === "number"
? { lastEventId: inFlightGeneration.replayFromEventId } ? { lastEventId: inFlightGeneration.replayFromEventId }

View File

@@ -12,6 +12,7 @@ import {
} from "../api"; } from "../api";
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, SWR_LONG_MAX_AGE_MS, writeCache } from "../utils/swrCache"; import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, SWR_LONG_MAX_AGE_MS, writeCache } from "../utils/swrCache";
const ACTIVE_ROOM_STORAGE_KEY = "fusion:chat-active-room"; const ACTIVE_ROOM_STORAGE_KEY = "fusion:chat-active-room";
@@ -324,6 +325,12 @@ export function useChatRooms(
return subscribeSse(eventsUrl, { return subscribeSse(eventsUrl, {
onReconnect: () => { onReconnect: () => {
recordResumeEvent({
view: "useChatRooms",
trigger: "sse-reconnect",
projectId,
replayAttempted: false,
});
void refreshRooms(); void refreshRooms();
}, },
events: { events: {

View File

@@ -5,6 +5,7 @@ import * as api from "../api";
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache"; import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
import { pushTrace } from "../utils/dashboardTraceBuffer"; import { pushTrace } from "../utils/dashboardTraceBuffer";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
const loggedTaskCacheHitProjects = new Set<string>(); const loggedTaskCacheHitProjects = new Set<string>();
@@ -252,6 +253,13 @@ export function useTasks(options?: UseTasksOptions) {
previousContextVersion, previousContextVersion,
currentContextVersion: projectContextVersionRef.current, currentContextVersion: projectContextVersionRef.current,
}); });
recordResumeEvent({
view: "useTasks",
trigger: "visibility",
projectId,
replayAttempted: false,
reason: "context-version-changed",
});
void refreshTasks(); void refreshTasks();
return; return;
} }
@@ -263,6 +271,13 @@ export function useTasks(options?: UseTasksOptions) {
} }
lastVisibilityRefreshRef.current = now; lastVisibilityRefreshRef.current = now;
recordResumeEvent({
view: "useTasks",
trigger: "visibility",
projectId,
replayAttempted: false,
reason: "debounced-refresh",
});
void refreshTasks(); void refreshTasks();
}; };
@@ -455,6 +470,12 @@ export function useTasks(options?: UseTasksOptions) {
traceDroppedStaleEvent(); traceDroppedStaleEvent();
return; return;
} }
recordResumeEvent({
view: "useTasks",
trigger: "sse-reconnect",
projectId,
replayAttempted: false,
});
void refreshTasksRef.current(); void refreshTasksRef.current();
}, },
}); });

View File

@@ -1,5 +1,6 @@
import { appendTokenQuery } from "./auth"; import { appendTokenQuery } from "./auth";
import { pushTrace } from "./utils/dashboardTraceBuffer"; import { pushTrace } from "./utils/dashboardTraceBuffer";
import { recordResumeEvent } from "./utils/resumeInstrumentation";
// Shared EventSource multiplexer. // Shared EventSource multiplexer.
// //
@@ -215,6 +216,13 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
const reopenSubscribedChannels = (event: PageTransitionEvent) => { const reopenSubscribedChannels = (event: PageTransitionEvent) => {
console.info("[sse-bus] pageshow", { persisted: event.persisted, channelCount: channels.size }); console.info("[sse-bus] pageshow", { persisted: event.persisted, channelCount: channels.size });
pushTrace("sse-bus", "pageshow", { persisted: event.persisted, channelCount: channels.size }); pushTrace("sse-bus", "pageshow", { persisted: event.persisted, channelCount: channels.size });
recordResumeEvent({
view: "sse-bus",
trigger: "pageshow",
replayAttempted: false,
sseChannel: "all",
detail: { persisted: event.persisted, channelCount: channels.size },
});
for (const channel of Array.from(channels.values())) { for (const channel of Array.from(channels.values())) {
if (channel.subscribers.size === 0) continue; if (channel.subscribers.size === 0) continue;
if (channel.es !== null && !channel.closed) continue; if (channel.es !== null && !channel.closed) continue;
@@ -231,6 +239,13 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
console.info("[sse-bus] visibilitychange", { visibilityState: document.visibilityState, channelCount: channels.size }); console.info("[sse-bus] visibilitychange", { visibilityState: document.visibilityState, channelCount: channels.size });
pushTrace("sse-bus", "visibilitychange", { visibilityState: document.visibilityState, channelCount: channels.size }); pushTrace("sse-bus", "visibilitychange", { visibilityState: document.visibilityState, channelCount: channels.size });
recordResumeEvent({
view: "sse-bus",
trigger: "visibility",
replayAttempted: false,
sseChannel: "all",
detail: { visibilityState: document.visibilityState, channelCount: channels.size },
});
for (const channel of Array.from(channels.values())) { for (const channel of Array.from(channels.values())) {
if (channel.subscribers.size === 0) continue; if (channel.subscribers.size === 0) continue;
@@ -277,6 +292,13 @@ function forceReconnect(channel: Channel, cause: "heartbeat-timeout" | "error" |
subscriberCount: channel.subscribers.size, subscriberCount: channel.subscribers.size,
hasOpenedOnce: channel.hasOpenedOnce, hasOpenedOnce: channel.hasOpenedOnce,
}); });
recordResumeEvent({
view: "sse-bus",
trigger: "sse-reconnect",
replayAttempted: false,
sseChannel: channel.url,
reason: cause,
});
if (channel.heartbeatTimer) { if (channel.heartbeatTimer) {
clearTimeout(channel.heartbeatTimer); clearTimeout(channel.heartbeatTimer);
channel.heartbeatTimer = null; channel.heartbeatTimer = null;
@@ -322,6 +344,18 @@ function openChannel(channel: Channel): void {
closed: channel.closed, closed: channel.closed,
hasEventSource: channel.es !== null, hasEventSource: channel.es !== null,
}); });
recordResumeEvent({
view: "sse-bus",
trigger: "sse-open",
replayAttempted: false,
sseChannel: channel.url,
detail: {
subscriberCount: channel.subscribers.size,
hasOpenedOnce: channel.hasOpenedOnce,
closed: channel.closed,
hasEventSource: channel.es !== null,
},
});
console.info("[sse-bus] openChannel", { console.info("[sse-bus] openChannel", {
url: channel.url, url: channel.url,
subscriberCount: channel.subscribers.size, subscriberCount: channel.subscribers.size,
@@ -354,10 +388,18 @@ function openChannel(channel: Channel): void {
es.addEventListener("error", (event) => { es.addEventListener("error", (event) => {
for (const sub of channel.subscribers) sub.onError?.(event); for (const sub of channel.subscribers) sub.onError?.(event);
recordResumeEvent({
view: "sse-bus",
trigger: "sse-error",
replayAttempted: false,
sseChannel: channel.url,
reason: "error",
});
// Any error triggers a forced reconnect cycle — matches the pre-bus // Any error triggers a forced reconnect cycle — matches the pre-bus
// behavior in useTasks and ensures the stream recovers even when // behavior in useTasks and ensures the stream recovers even when
// EventSource's own retry has stalled. // EventSource's own retry has stalled.
forceReconnect(channel, "error"); }); forceReconnect(channel, "error");
});
// Unnamed `message` events and server "heartbeat" events both count as // Unnamed `message` events and server "heartbeat" events both count as
// liveness signals, regardless of whether a subscriber registered them. // liveness signals, regardless of whether a subscriber registered them.

View File

@@ -0,0 +1,124 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearResumeEvents,
getResumeEvents,
recordResumeEvent,
setResumeInstrumentationEnabled,
type ResumeTrigger,
} from "../resumeInstrumentation";
const triggers: ResumeTrigger[] = [
"visibility",
"pageshow",
"sse-error",
"sse-reconnect",
"sse-open",
"remount",
"route-active",
"route-inactive",
"project-context-change",
];
describe("resumeInstrumentation", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-20T12:00:00.000Z"));
clearResumeEvents();
setResumeInstrumentationEnabled(true);
});
afterEach(() => {
vi.useRealTimers();
});
it("records all trigger values", () => {
for (const trigger of triggers) {
recordResumeEvent({ view: "test", trigger, replayAttempted: false });
}
expect(getResumeEvents().map((event) => event.trigger)).toEqual(triggers);
});
it("computes gapMs with fake timers", () => {
const first = recordResumeEvent({ view: "gap", trigger: "visibility", replayAttempted: false });
expect(first.gapMs).toBeUndefined();
vi.advanceTimersByTime(250);
const second = recordResumeEvent({ view: "gap", trigger: "visibility", replayAttempted: false });
expect(second.gapMs).toBe(250);
});
it("isolates per-view gaps while falling back to global baseline", () => {
recordResumeEvent({ view: "a", trigger: "remount", replayAttempted: false, now: 1000 });
const other = recordResumeEvent({ view: "b", trigger: "remount", replayAttempted: false, now: 1500 });
const againA = recordResumeEvent({ view: "a", trigger: "route-active", replayAttempted: false, now: 1800 });
expect(other.gapMs).toBe(500);
expect(againA.gapMs).toBe(800);
});
it("caps ring at 500", () => {
for (let i = 0; i < 520; i += 1) {
recordResumeEvent({
view: "cap",
trigger: "visibility",
replayAttempted: false,
detail: { i },
});
}
const events = getResumeEvents();
expect(events).toHaveLength(500);
expect(events[0]?.detail).toEqual({ i: 20 });
expect(events[499]?.detail).toEqual({ i: 519 });
});
it("is a no-op for buffering and posting when disabled", async () => {
const fetchSpy = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchSpy);
setResumeInstrumentationEnabled(false);
const event = recordResumeEvent({ view: "disabled", trigger: "visibility", replayAttempted: false });
expect(event.ts).toBeDefined();
expect(getResumeEvents()).toHaveLength(0);
await vi.runAllTimersAsync();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("posts batched events with payload cap of 25", async () => {
const fetchSpy = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchSpy);
for (let i = 0; i < 30; i += 1) {
recordResumeEvent({
view: "batch",
trigger: "sse-open",
replayAttempted: false,
detail: { i },
});
}
await vi.runAllTimersAsync();
expect(fetchSpy).toHaveBeenCalledTimes(2);
const firstBody = JSON.parse(String(fetchSpy.mock.calls[0]?.[1]?.body));
const secondBody = JSON.parse(String(fetchSpy.mock.calls[1]?.[1]?.body));
expect(firstBody.events).toHaveLength(25);
expect(secondBody.events).toHaveLength(5);
expect(fetchSpy.mock.calls[0]?.[0]).toBe("/api/diagnostics/resume-events");
});
it("swallows network failures", async () => {
const fetchSpy = vi.fn().mockRejectedValue(new Error("offline"));
vi.stubGlobal("fetch", fetchSpy);
recordResumeEvent({ view: "network", trigger: "sse-error", replayAttempted: false });
await vi.runAllTimersAsync();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,155 @@
import { pushTrace } from "./dashboardTraceBuffer";
export type ResumeTrigger =
| "visibility"
| "pageshow"
| "sse-error"
| "sse-reconnect"
| "sse-open"
| "remount"
| "route-active"
| "route-inactive"
| "project-context-change";
export type ResumeEvent = {
ts: string;
view: string;
trigger: ResumeTrigger;
projectId?: string;
gapMs?: number;
replayAttempted: boolean;
replayFromEventId?: number | null;
lastEventId?: number | null;
sseChannel?: string;
reason?: string;
detail?: Record<string, unknown>;
};
const RESUME_CAP = 500;
const POST_BATCH_CAP = 25;
const resumeEvents: ResumeEvent[] = [];
const pendingBatch: ResumeEvent[] = [];
const lastActivityByView = new Map<string, number>();
let lastActivityGlobal: number | undefined;
let flushScheduled = false;
let enabled = true;
function nowTs(now: number): string {
return new Date(now).toISOString();
}
function enqueueFlush(): void {
if (flushScheduled || pendingBatch.length === 0 || typeof window === "undefined") {
return;
}
flushScheduled = true;
const schedule = (cb: () => void) => {
if (typeof window.requestIdleCallback === "function") {
window.requestIdleCallback(cb);
return;
}
window.setTimeout(cb, 0);
};
schedule(() => {
flushScheduled = false;
void flushPendingBatch();
});
}
async function flushPendingBatch(): Promise<void> {
if (pendingBatch.length === 0 || typeof window === "undefined" || typeof globalThis.fetch !== "function") {
return;
}
const events = pendingBatch.splice(0, POST_BATCH_CAP);
try {
await globalThis.fetch("/api/diagnostics/resume-events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events }),
});
} catch {
// swallow instrumentation failures
}
if (pendingBatch.length > 0) {
enqueueFlush();
}
}
export function getResumeEvents(): ResumeEvent[] {
return [...resumeEvents];
}
export function clearResumeEvents(): void {
resumeEvents.length = 0;
pendingBatch.length = 0;
flushScheduled = false;
lastActivityByView.clear();
lastActivityGlobal = undefined;
}
export function setResumeInstrumentationEnabled(nextEnabled: boolean): void {
enabled = nextEnabled;
}
export function recordResumeEvent(
event: Omit<ResumeEvent, "ts" | "gapMs"> & { now?: number },
): ResumeEvent {
const now = event.now ?? Date.now();
const previousByView = lastActivityByView.get(event.view);
const previousGlobal = lastActivityGlobal;
const baseline = previousByView ?? previousGlobal;
const stampedEvent: ResumeEvent = {
...event,
ts: nowTs(now),
gapMs: baseline !== undefined ? Math.max(0, now - baseline) : undefined,
};
delete (stampedEvent as { now?: number }).now;
lastActivityByView.set(event.view, now);
lastActivityGlobal = now;
if (!enabled) {
return stampedEvent;
}
pushTrace("resumeInstrumentation", event.trigger, stampedEvent as unknown as Record<string, unknown>);
resumeEvents.push(stampedEvent);
if (resumeEvents.length > RESUME_CAP) {
resumeEvents.splice(0, resumeEvents.length - RESUME_CAP);
}
pendingBatch.push(stampedEvent);
enqueueFlush();
return stampedEvent;
}
declare global {
interface Window {
__fusionDebug?: {
resumeInstrumentation?: {
get: typeof getResumeEvents;
clear: typeof clearResumeEvents;
setEnabled: typeof setResumeInstrumentationEnabled;
};
};
}
}
if (typeof window !== "undefined") {
window.__fusionDebug ??= {};
window.__fusionDebug.resumeInstrumentation = {
get: getResumeEvents,
clear: clearResumeEvents,
setEnabled: setResumeInstrumentationEnabled,
};
}

View File

@@ -167,6 +167,7 @@ import { registerAuthRoutes } from "./routes/register-auth-routes.js";
import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provider-routes.js"; import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provider-routes.js";
import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js"; import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js";
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js"; import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
import { registerDiagnosticsRoutes } from "./routes/register-diagnostics-routes.js";
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js"; import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
import { registerApprovalRoutes } from "./routes/register-approval-routes.js"; import { registerApprovalRoutes } from "./routes/register-approval-routes.js";
import { registerWorktrunkRoutes } from "./routes/register-worktrunk-routes.js"; import { registerWorktrunkRoutes } from "./routes/register-worktrunk-routes.js";
@@ -1865,6 +1866,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
registerUsageRoutes(routeContext); registerUsageRoutes(routeContext);
registerUpdateCheckRoutes(routeContext); registerUpdateCheckRoutes(routeContext);
registerDiagnosticsRoutes(routeContext);
// ── Automation / Scheduled Task Routes ──────────────────────────── // ── Automation / Scheduled Task Routes ────────────────────────────
// //

View File

@@ -0,0 +1,169 @@
// @vitest-environment node
import express from "express";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { request as performRequest } from "../../test-request.js";
import {
__resetResumeDiagnosticsForTests,
__setResumeDiagnosticsCapForTests,
registerDiagnosticsRoutes,
} from "../register-diagnostics-routes.js";
function createApp(getProjectContext = vi.fn(async () => ({ projectId: "proj-1", store: {} }))) {
const router = express.Router();
const rethrowAsApiError = vi.fn((error: unknown) => {
throw error;
});
registerDiagnosticsRoutes({
router,
store: {} as never,
runtimeLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never,
planningLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never,
chatLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never,
getProjectIdFromRequest: vi.fn(() => "proj-1"),
getScopedStore: vi.fn(async () => ({}) as never),
getProjectContext,
prioritizeProjectsForCurrentDirectory: vi.fn((projects: Array<{ path: string }>) => projects),
emitRemoteRouteDiagnostic: vi.fn(),
emitAuthSyncAuditLog: vi.fn(),
parseScopeParam: vi.fn(),
resolveAutomationStore: vi.fn() as never,
resolveRoutineStore: vi.fn() as never,
resolveRoutineRunner: vi.fn() as never,
registerDispose: vi.fn(),
dispose: vi.fn(),
rethrowAsApiError,
});
const app = express();
app.use(express.json());
app.use("/api", router);
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err?.statusCode ?? 500).json({ error: err?.message ?? String(err) });
});
return { app, getProjectContext, rethrowAsApiError };
}
const validEvent = {
ts: "2026-05-20T12:00:00.000Z",
view: "useTasks",
trigger: "visibility",
replayAttempted: false,
detail: { reason: "debounced-refresh" },
};
describe("register-diagnostics-routes", () => {
beforeEach(() => {
__resetResumeDiagnosticsForTests();
});
it("accepts valid POST payload", async () => {
const { app } = createApp();
const response = await performRequest(
app,
"POST",
"/api/diagnostics/resume-events",
JSON.stringify({ events: [validEvent] }),
{ "Content-Type": "application/json" },
);
expect(response.status).toBe(200);
expect(response.body).toEqual({ ok: true, accepted: 1 });
});
it("rejects invalid payloads", async () => {
const { app } = createApp();
const oversized = await performRequest(
app,
"POST",
"/api/diagnostics/resume-events",
JSON.stringify({ events: new Array(101).fill(validEvent) }),
{ "Content-Type": "application/json" },
);
expect(oversized.status).toBe(400);
const badTrigger = await performRequest(
app,
"POST",
"/api/diagnostics/resume-events",
JSON.stringify({ events: [{ ...validEvent, trigger: "unknown" }] }),
{ "Content-Type": "application/json" },
);
expect(badTrigger.status).toBe(400);
const hugeDetail = await performRequest(
app,
"POST",
"/api/diagnostics/resume-events",
JSON.stringify({ events: [{ ...validEvent, detail: { blob: "x".repeat(5000) } }] }),
{ "Content-Type": "application/json" },
);
expect(hugeDetail.status).toBe(400);
});
it("supports GET filters by since and view", async () => {
const { app } = createApp();
await performRequest(
app,
"POST",
"/api/diagnostics/resume-events",
JSON.stringify({
events: [
validEvent,
{ ...validEvent, ts: "2026-05-20T12:10:00.000Z", view: "useChatRooms", trigger: "sse-reconnect" },
],
}),
{ "Content-Type": "application/json" },
);
const response = await performRequest(app, "GET", "/api/diagnostics/resume-events?since=2026-05-20T12:05:00.000Z&view=useChatRooms");
expect(response.status).toBe(200);
expect(response.body.events).toHaveLength(1);
expect(response.body.events[0]).toMatchObject({ view: "useChatRooms" });
});
it("tracks ring overflow and returns droppedSinceLastRead", async () => {
const { app } = createApp();
__setResumeDiagnosticsCapForTests(200);
const chunk = new Array(100).fill(null).map((_, idx) => ({
...validEvent,
ts: new Date(Date.UTC(2026, 4, 20, 12, 0, 0, idx)).toISOString(),
detail: { idx },
}));
for (let i = 0; i < 3; i += 1) {
await performRequest(
app,
"POST",
"/api/diagnostics/resume-events",
JSON.stringify({ events: chunk }),
{ "Content-Type": "application/json" },
);
}
const response = await performRequest(app, "GET", "/api/diagnostics/resume-events?limit=5000");
expect(response.status).toBe(200);
expect(response.body.events).toHaveLength(200);
expect(response.body.droppedSinceLastRead).toBe(100);
const secondRead = await performRequest(app, "GET", "/api/diagnostics/resume-events?limit=1");
expect(secondRead.body.droppedSinceLastRead).toBe(0);
});
it("uses getProjectContext / rethrow flow on auth errors", async () => {
const getProjectContext = vi.fn(async () => {
throw new Error("unauthorized");
});
const { app, rethrowAsApiError } = createApp(getProjectContext);
const response = await performRequest(app, "GET", "/api/diagnostics/resume-events");
expect(response.status).toBe(500);
expect(rethrowAsApiError).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,138 @@
import type { ApiRouteRegistrar } from "./types.js";
let resumeRingCap = 5_000;
const ACCEPT_CAP = 100;
const DETAIL_CAP_BYTES = 4 * 1024;
const triggers = new Set([
"visibility",
"pageshow",
"sse-error",
"sse-reconnect",
"sse-open",
"remount",
"route-active",
"route-inactive",
"project-context-change",
]);
type ResumeEvent = {
ts: string;
view: string;
trigger: string;
projectId?: string;
gapMs?: number;
replayAttempted: boolean;
replayFromEventId?: number | null;
lastEventId?: number | null;
sseChannel?: string;
reason?: string;
detail?: Record<string, unknown>;
};
const resumeEvents: ResumeEvent[] = [];
let droppedCount = 0;
function isIsoDate(value: unknown): value is string {
if (typeof value !== "string") return false;
if (Number.isNaN(Date.parse(value))) return false;
return new Date(value).toISOString() === value;
}
function isJsonSafe(value: unknown): boolean {
try {
JSON.stringify(value);
return true;
} catch {
return false;
}
}
function validateEvent(event: unknown): event is ResumeEvent {
if (!event || typeof event !== "object") return false;
const candidate = event as Record<string, unknown>;
if (!isIsoDate(candidate.ts)) return false;
if (typeof candidate.view !== "string" || candidate.view.length === 0 || candidate.view.length > 64) return false;
if (typeof candidate.trigger !== "string" || !triggers.has(candidate.trigger)) return false;
if (typeof candidate.replayAttempted !== "boolean") return false;
if (candidate.detail !== undefined) {
if (!isJsonSafe(candidate.detail)) return false;
if (Buffer.byteLength(JSON.stringify(candidate.detail), "utf8") > DETAIL_CAP_BYTES) return false;
}
return true;
}
function appendEvents(events: ResumeEvent[]): void {
resumeEvents.push(...events);
if (resumeEvents.length > resumeRingCap) {
const overflow = resumeEvents.length - resumeRingCap;
droppedCount += overflow;
resumeEvents.splice(0, overflow);
}
}
export const registerDiagnosticsRoutes: ApiRouteRegistrar = (ctx) => {
const { router } = ctx;
router.post("/diagnostics/resume-events", async (req, res) => {
try {
await ctx.getProjectContext(req);
const body = req.body as { events?: unknown };
const events = body?.events;
if (!Array.isArray(events) || events.length > ACCEPT_CAP) {
res.status(400).json({ error: "Invalid events payload" });
return;
}
if (!events.every(validateEvent)) {
res.status(400).json({ error: "Invalid resume event entry" });
return;
}
appendEvents(events);
res.json({ ok: true, accepted: events.length });
} catch (error) {
ctx.rethrowAsApiError(error, "Failed to store resume diagnostics events");
}
});
router.get("/diagnostics/resume-events", async (req, res) => {
try {
await ctx.getProjectContext(req);
const limit = Math.max(1, Math.min(Number(req.query.limit ?? 100) || 100, resumeRingCap));
const since = typeof req.query.since === "string" ? Date.parse(req.query.since) : NaN;
const view = typeof req.query.view === "string" ? req.query.view : undefined;
let filtered = resumeEvents;
if (!Number.isNaN(since)) {
filtered = filtered.filter((event) => Date.parse(event.ts) >= since);
}
if (view) {
filtered = filtered.filter((event) => event.view === view);
}
const events = filtered.slice(-limit);
const droppedSinceLastRead = droppedCount;
droppedCount = 0;
res.json({ events, droppedSinceLastRead });
} catch (error) {
ctx.rethrowAsApiError(error, "Failed to read resume diagnostics events");
}
});
};
export function __resetResumeDiagnosticsForTests(): void {
resumeEvents.length = 0;
droppedCount = 0;
resumeRingCap = 5_000;
}
export function __setResumeDiagnosticsCapForTests(cap: number): void {
resumeRingCap = Math.max(1, Math.floor(cap));
}

View File

@@ -15,7 +15,7 @@ const qualityAppTests = [
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx", "app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior. // Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx", "app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}", "app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
"app/utils/**/*.test.{ts,tsx}", "app/utils/**/*.test.{ts,tsx}",
]; ];
@@ -23,7 +23,7 @@ const qualityApiTests = [
// Critical HTTP/server behavior: auth, task/project/settings mutation, // Critical HTTP/server behavior: auth, task/project/settings mutation,
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards. // git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-nodes-sync-contract,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts", "src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-nodes-sync-contract,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts",
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,stash-recovery-routes}.test.ts", "src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,register-diagnostics-routes,stash-recovery-routes}.test.ts",
]; ];
export default defineConfig({ export default defineConfig({