nexus/ui/src/context/LiveUpdatesProvider.tsx
Forgotten 0131cf3449 Support concurrent heartbeat runs with maxConcurrentRuns policy
Add per-agent maxConcurrentRuns (1-10) controlling how many runs
execute simultaneously. Implements agent-level start lock, optimistic
claim-then-execute flow, atomic token accounting via SQL expressions,
and proper status resolution when parallel runs finish. Updates UI
config form, live run count display, and SSE invalidation to avoid
unnecessary refetches on run event streams.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:34 -06:00

204 lines
6.7 KiB
TypeScript

import { useEffect, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclip/shared";
import { useCompany } from "./CompanyContext";
import { queryKeys } from "../lib/queryKeys";
function readString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function invalidateHeartbeatQueries(
queryClient: ReturnType<typeof useQueryClient>,
companyId: string,
payload: Record<string, unknown>,
) {
queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
const agentId = readString(payload.agentId);
if (agentId) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, agentId) });
}
}
function invalidateActivityQueries(
queryClient: ReturnType<typeof useQueryClient>,
companyId: string,
payload: Record<string, unknown>,
) {
queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
const entityType = readString(payload.entityType);
const entityId = readString(payload.entityId);
if (entityType === "issue") {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
if (entityId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(entityId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(entityId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(entityId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(entityId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(entityId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(entityId) });
}
return;
}
if (entityType === "agent") {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.org(companyId) });
if (entityId) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(entityId) });
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, entityId) });
}
return;
}
if (entityType === "project") {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(companyId) });
if (entityId) queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(entityId) });
return;
}
if (entityType === "goal") {
queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(companyId) });
if (entityId) queryClient.invalidateQueries({ queryKey: queryKeys.goals.detail(entityId) });
return;
}
if (entityType === "approval") {
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) });
return;
}
if (entityType === "cost_event") {
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
return;
}
if (entityType === "company") {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
}
}
function handleLiveEvent(
queryClient: ReturnType<typeof useQueryClient>,
expectedCompanyId: string,
event: LiveEvent,
) {
if (event.companyId !== expectedCompanyId) return;
const payload = event.payload ?? {};
if (event.type === "heartbeat.run.log") {
return;
}
if (event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status") {
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
return;
}
if (event.type === "heartbeat.run.event") {
return;
}
if (event.type === "agent.status") {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(expectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(expectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.org(expectedCompanyId) });
const agentId = readString(payload.agentId);
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
return;
}
if (event.type === "activity.logged") {
invalidateActivityQueries(queryClient, expectedCompanyId, payload);
}
}
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
useEffect(() => {
if (!selectedCompanyId) return;
let closed = false;
let reconnectAttempt = 0;
let reconnectTimer: number | null = null;
let socket: WebSocket | null = null;
const clearReconnect = () => {
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
const scheduleReconnect = () => {
if (closed) return;
reconnectAttempt += 1;
const delayMs = Math.min(15000, 1000 * 2 ** Math.min(reconnectAttempt - 1, 4));
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
connect();
}, delayMs);
};
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(selectedCompanyId)}/events/ws`;
socket = new WebSocket(url);
socket.onopen = () => {
reconnectAttempt = 0;
};
socket.onmessage = (message) => {
const raw = typeof message.data === "string" ? message.data : "";
if (!raw) return;
try {
const parsed = JSON.parse(raw) as LiveEvent;
handleLiveEvent(queryClient, selectedCompanyId, parsed);
} catch {
// Ignore non-JSON payloads.
}
};
socket.onerror = () => {
socket?.close();
};
socket.onclose = () => {
if (closed) return;
scheduleReconnect();
};
};
connect();
return () => {
closed = true;
clearReconnect();
if (socket) {
socket.onopen = null;
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
socket.close(1000, "provider_unmount");
}
};
}, [queryClient, selectedCompanyId]);
return <>{children}</>;
}