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:
committed by
gsxdsm
parent
12582414da
commit
1913cb59fe
@@ -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)`.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
155
packages/dashboard/app/utils/resumeInstrumentation.ts
Normal file
155
packages/dashboard/app/utils/resumeInstrumentation.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 ────────────────────────────
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
138
packages/dashboard/src/routes/register-diagnostics-routes.ts
Normal file
138
packages/dashboard/src/routes/register-diagnostics-routes.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user