From 92aef9bae842c6231966026487d3e683cd529807 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 21:16:33 -0500 Subject: [PATCH] Slim heartbeat run list payloads --- server/src/routes/agents.ts | 11 ++++++ server/src/services/heartbeat.ts | 67 +++++++++++++++++++++++++++++++- ui/src/api/heartbeats.ts | 1 + ui/src/lib/queryKeys.ts | 1 + ui/src/pages/AgentDetail.tsx | 8 +++- 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index d150bb10..c27b893a 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1346,6 +1346,17 @@ export function agentRoutes(db: Db) { res.json(liveRuns); }); + router.get("/heartbeat-runs/:runId", async (req, res) => { + const runId = req.params.runId as string; + const run = await heartbeat.getRun(runId); + if (!run) { + res.status(404).json({ error: "Heartbeat run not found" }); + return; + } + assertCompanyAccess(req, run.companyId); + res.json(run); + }); + router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { assertBoard(req); const runId = req.params.runId as string; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index daa3ac69..af0952ac 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -46,6 +46,69 @@ const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; const startLocksByAgent = new Map>(); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; +const summarizedHeartbeatRunResultJson = sql | null>` + CASE + WHEN ${heartbeatRuns.resultJson} IS NULL THEN NULL + ELSE NULLIF( + jsonb_strip_nulls( + jsonb_build_object( + 'summary', CASE + WHEN ${heartbeatRuns.resultJson} ->> 'summary' IS NULL THEN NULL + ELSE left(${heartbeatRuns.resultJson} ->> 'summary', 500) + END, + 'result', CASE + WHEN ${heartbeatRuns.resultJson} ->> 'result' IS NULL THEN NULL + ELSE left(${heartbeatRuns.resultJson} ->> 'result', 500) + END, + 'message', CASE + WHEN ${heartbeatRuns.resultJson} ->> 'message' IS NULL THEN NULL + ELSE left(${heartbeatRuns.resultJson} ->> 'message', 500) + END, + 'error', CASE + WHEN ${heartbeatRuns.resultJson} ->> 'error' IS NULL THEN NULL + ELSE left(${heartbeatRuns.resultJson} ->> 'error', 500) + END, + 'total_cost_usd', ${heartbeatRuns.resultJson} -> 'total_cost_usd', + 'cost_usd', ${heartbeatRuns.resultJson} -> 'cost_usd', + 'costUsd', ${heartbeatRuns.resultJson} -> 'costUsd' + ) + ), + '{}'::jsonb + ) + END +`; + +const heartbeatRunListColumns = { + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + agentId: heartbeatRuns.agentId, + invocationSource: heartbeatRuns.invocationSource, + triggerDetail: heartbeatRuns.triggerDetail, + status: heartbeatRuns.status, + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + error: heartbeatRuns.error, + wakeupRequestId: heartbeatRuns.wakeupRequestId, + exitCode: heartbeatRuns.exitCode, + signal: heartbeatRuns.signal, + usageJson: heartbeatRuns.usageJson, + resultJson: summarizedHeartbeatRunResultJson.as("resultJson"), + sessionIdBefore: heartbeatRuns.sessionIdBefore, + sessionIdAfter: heartbeatRuns.sessionIdAfter, + logStore: heartbeatRuns.logStore, + logRef: heartbeatRuns.logRef, + logBytes: heartbeatRuns.logBytes, + logSha256: heartbeatRuns.logSha256, + logCompressed: heartbeatRuns.logCompressed, + stdoutExcerpt: sql`NULL`.as("stdoutExcerpt"), + stderrExcerpt: sql`NULL`.as("stderrExcerpt"), + errorCode: heartbeatRuns.errorCode, + externalRunId: heartbeatRuns.externalRunId, + contextSnapshot: heartbeatRuns.contextSnapshot, + createdAt: heartbeatRuns.createdAt, + updatedAt: heartbeatRuns.updatedAt, +} as const; + function appendExcerpt(prev: string, chunk: string) { return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES); } @@ -2260,9 +2323,9 @@ export function heartbeatService(db: Db) { } return { - list: (companyId: string, agentId?: string, limit?: number) => { + list: async (companyId: string, agentId?: string, limit?: number) => { const query = db - .select() + .select(heartbeatRunListColumns) .from(heartbeatRuns) .where( agentId diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 680412da..b579a65d 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -29,6 +29,7 @@ export const heartbeatsApi = { const qs = searchParams.toString(); return api.get(`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`); }, + get: (runId: string) => api.get(`/heartbeat-runs/${runId}`), events: (runId: string, afterSeq = 0, limit = 200) => api.get( `/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`, diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 34791488..ff73701f 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -68,6 +68,7 @@ export const queryKeys = { ["costs", companyId, from, to] as const, heartbeats: (companyId: string, agentId?: string) => ["heartbeats", companyId, agentId] as const, + runDetail: (runId: string) => ["heartbeat-run", runId] as const, liveRuns: (companyId: string) => ["live-runs", companyId] as const, runIssues: (runId: string) => ["run-issues", runId] as const, org: (companyId: string) => ["org", companyId] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 5f721644..b5571255 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1254,9 +1254,15 @@ function RunsTab({ /* ---- Run Detail (expanded) ---- */ -function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) { +function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) { const queryClient = useQueryClient(); const navigate = useNavigate(); + const { data: hydratedRun } = useQuery({ + queryKey: queryKeys.runDetail(initialRun.id), + queryFn: () => heartbeatsApi.get(initialRun.id), + enabled: Boolean(initialRun.id), + }); + const run = hydratedRun ?? initialRun; const metrics = runMetrics(run); const [sessionOpen, setSessionOpen] = useState(false); const [claudeLoginResult, setClaudeLoginResult] = useState(null);