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>
204 lines
6.7 KiB
TypeScript
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}</>;
|
|
}
|