## Summary Wave 11 of package code organization (continues #2328 / wave 10), after syncing main into the wave10 stack. ### Peels | New module | Parent | |---|---| | `app/api/event-source.ts` | resilient `EventSource` reconnect + pagehide cleanup | | `app/api/chat.ts` | chat sessions / rooms / streaming | | `app/api/projects.ts` | multi-project management client | | `app/api/agents.ts` | agent CRUD / soul / memory | | `app/api/workflows.ts` | workflow definition client | | `app/api/scheduling.ts` | automations + routines | | `app/api/ai-text.ts` | text refine / import translate / subtasks | | `app/api/ai-sessions.ts` | AI / planning session client + `startKeepAlive` | | `app/api/research.ts` | research + evals client | | `task-store/branch-and-pr-entities.ts` | rename of `remaining-ops-6` | Public paths stay stable via `legacy.ts` re-exports (`app/api.ts` → `legacy`). ### LOC - `legacy.ts` ~8921 → ~5670 ### Shims - `legacy.ts` → peels above (delete-when: dashboard imports domain modules) - `remaining-ops-6` → `branch-and-pr-entities` (rename complete) ## Test plan - [x] eslint on peeled API modules + legacy - [x] dashboard app typecheck (`app/api/*` clean) - [x] core typecheck - [x] `plugin-setup-api` tests - [ ] CI merge gate <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Expanded dashboard APIs for agents, AI sessions, chat rooms/sessions, research runs, projects, workflows, routines/automations, and scheduling. * Added AI text refinement/translation, planning title regeneration, and streamed subtask breakdown with task creation. * Introduced resilient server-sent-events streaming for chat and other live updates. * Added workflow export/import and workflow design support, plus scripts and workflow configuration controls. * Added research export and ability to attach research outputs to tasks. * **Enhancements** * Missions support clearing an auto-merge override back to project defaults. * Subtasks can now include an optional priority when creating tasks. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
160 lines
4.8 KiB
TypeScript
160 lines
4.8 KiB
TypeScript
/**
|
|
* FNXC:CodeOrganization 2026-07-19-12:00:
|
|
* Resilient EventSource client peeled from legacy.ts for shared SSE reconnect semantics.
|
|
*/
|
|
import { appendTokenQuery } from "../auth";
|
|
|
|
export type StreamConnectionState = "connected" | "reconnecting";
|
|
|
|
// Track every live createResilientEventSource instance so we can close their
|
|
// underlying EventSource sockets on page unload. Without this, Chrome holds
|
|
// the HTTP/1.1 sockets open in its keep-alive pool across refreshes, exhausts
|
|
// its 6-per-origin limit after ~3 refreshes, and every new fetch stalls —
|
|
// leaving the dashboard frozen on "Initializing...". sse-bus.ts has its own
|
|
// handler; this one covers the parallel EventSource path in api.ts.
|
|
const activeResilientEventSources = new Set<{ close: () => void }>();
|
|
if (typeof window !== "undefined") {
|
|
const closeAll = () => {
|
|
for (const handle of Array.from(activeResilientEventSources)) {
|
|
try { handle.close(); } catch { /* best effort */ }
|
|
}
|
|
};
|
|
window.addEventListener("pagehide", closeAll);
|
|
window.addEventListener("beforeunload", closeAll);
|
|
}
|
|
|
|
export interface ResilientEventSourceOptions {
|
|
maxReconnectAttempts?: number;
|
|
onConnectionStateChange?: (state: StreamConnectionState) => void;
|
|
onFatalError?: (message: string) => void;
|
|
}
|
|
|
|
export interface ResilientEventHandlers {
|
|
onOpen?: () => void;
|
|
onMessage?: (event: MessageEvent) => void;
|
|
events?: Record<string, (event: MessageEvent) => void>;
|
|
}
|
|
|
|
function appendLastEventId(url: string, lastEventId: number | null): string {
|
|
if (lastEventId === null || lastEventId <= 0) {
|
|
return url;
|
|
}
|
|
|
|
const separator = url.includes("?") ? "&" : "?";
|
|
return `${url}${separator}lastEventId=${encodeURIComponent(String(lastEventId))}`;
|
|
}
|
|
|
|
export function createResilientEventSource(
|
|
url: string,
|
|
handlers: ResilientEventHandlers,
|
|
options: ResilientEventSourceOptions = {},
|
|
): { close: () => void; isConnected: () => boolean } {
|
|
const maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
|
|
let eventSource: EventSource | null = null;
|
|
let closedByUser = false;
|
|
let reconnectAttempts = 0;
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let lastSeenEventId: number | null = null;
|
|
let reconnectingNotified = false;
|
|
|
|
const shouldDispatch = (event: MessageEvent): boolean => {
|
|
const rawId = event.lastEventId;
|
|
if (!rawId) {
|
|
return true;
|
|
}
|
|
|
|
const parsedId = Number.parseInt(rawId, 10);
|
|
if (!Number.isFinite(parsedId)) {
|
|
return true;
|
|
}
|
|
|
|
if (lastSeenEventId !== null && parsedId <= lastSeenEventId) {
|
|
return false;
|
|
}
|
|
|
|
lastSeenEventId = parsedId;
|
|
return true;
|
|
};
|
|
|
|
const connect = (): void => {
|
|
if (closedByUser) return;
|
|
|
|
const nextUrl = appendLastEventId(url, lastSeenEventId);
|
|
// EventSource can't set headers — carry the bearer token via `fn_token=`.
|
|
const source = new EventSource(appendTokenQuery(nextUrl));
|
|
eventSource = source;
|
|
|
|
source.onopen = () => {
|
|
reconnectAttempts = 0;
|
|
reconnectingNotified = false;
|
|
options.onConnectionStateChange?.("connected");
|
|
handlers.onOpen?.();
|
|
};
|
|
|
|
source.onmessage = (event) => {
|
|
const messageEvent = event as MessageEvent;
|
|
if (!shouldDispatch(messageEvent)) return;
|
|
handlers.onMessage?.(messageEvent);
|
|
};
|
|
|
|
for (const [eventName, handler] of Object.entries(handlers.events ?? {})) {
|
|
source.addEventListener(eventName, (event: Event) => {
|
|
const messageEvent = event as MessageEvent;
|
|
if (!shouldDispatch(messageEvent)) return;
|
|
handler(messageEvent);
|
|
});
|
|
}
|
|
|
|
source.onerror = () => {
|
|
if (closedByUser || eventSource !== source) return;
|
|
|
|
const readyState = source.readyState;
|
|
if (readyState === EventSource.CONNECTING) {
|
|
if (!reconnectingNotified) {
|
|
reconnectingNotified = true;
|
|
options.onConnectionStateChange?.("reconnecting");
|
|
}
|
|
return;
|
|
}
|
|
|
|
source.close();
|
|
if (eventSource === source) {
|
|
eventSource = null;
|
|
}
|
|
|
|
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
options.onFatalError?.("Connection lost");
|
|
return;
|
|
}
|
|
|
|
reconnectingNotified = true;
|
|
options.onConnectionStateChange?.("reconnecting");
|
|
reconnectAttempts += 1;
|
|
|
|
const delayMs = Math.min(1000 * 2 ** (reconnectAttempts - 1), 30000);
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = null;
|
|
connect();
|
|
}, delayMs);
|
|
};
|
|
};
|
|
|
|
connect();
|
|
|
|
const handle = {
|
|
close: () => {
|
|
closedByUser = true;
|
|
if (reconnectTimer) {
|
|
clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
}
|
|
eventSource?.close();
|
|
activeResilientEventSources.delete(handle);
|
|
},
|
|
isConnected: () => !closedByUser && eventSource?.readyState === EventSource.OPEN,
|
|
};
|
|
activeResilientEventSources.add(handle);
|
|
return handle;
|
|
}
|
|
|