diff --git a/server/src/__tests__/heartbeat-run-log.test.ts b/server/src/__tests__/heartbeat-run-log.test.ts new file mode 100644 index 00000000000..e88f976dd58 --- /dev/null +++ b/server/src/__tests__/heartbeat-run-log.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { compactRunLogChunk } from "../services/heartbeat.js"; + +describe("compactRunLogChunk", () => { + it("redacts inline base64 image data from structured log chunks", () => { + const base64 = "A".repeat(4096); + const chunk = `{"type":"user","message":{"content":[{"type":"image","source":{"type":"base64","data":"${base64}"}}]}}\n`; + + const compacted = compactRunLogChunk(chunk); + + expect(compacted).not.toContain(base64); + expect(compacted).toContain("[omitted base64 image data: 4096 chars]"); + }); + + it("truncates oversized chunks after sanitizing them", () => { + const chunk = `${"x".repeat(90_000)}tail`; + + const compacted = compactRunLogChunk(chunk, 16_384); + + expect(compacted.length).toBeLessThan(chunk.length); + expect(compacted).toContain("[paperclip truncated run log chunk:"); + expect(compacted.endsWith("tail")).toBe(true); + }); +}); diff --git a/server/src/__tests__/http-log-policy.test.ts b/server/src/__tests__/http-log-policy.test.ts index 98b1f121479..5f21836adea 100644 --- a/server/src/__tests__/http-log-policy.test.ts +++ b/server/src/__tests__/http-log-policy.test.ts @@ -8,6 +8,20 @@ describe("shouldSilenceHttpSuccessLog", () => { it("silences successful polling endpoints", () => { expect(shouldSilenceHttpSuccessLog("GET", "/api/health", 200)).toBe(true); + expect( + shouldSilenceHttpSuccessLog( + "GET", + "/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/heartbeat-runs", + 200, + ), + ).toBe(true); + expect( + shouldSilenceHttpSuccessLog( + "GET", + "/api/heartbeat-runs/b7044268-19b6-4b3a-a9f3-9c57dce70253/log?offset=1103894&limitBytes=256000", + 200, + ), + ).toBe(true); expect( shouldSilenceHttpSuccessLog( "GET", @@ -22,6 +36,20 @@ describe("shouldSilenceHttpSuccessLog", () => { 200, ), ).toBe(true); + expect( + shouldSilenceHttpSuccessLog( + "GET", + "/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/issues?includeRoutineExecutions=true", + 200, + ), + ).toBe(true); + expect( + shouldSilenceHttpSuccessLog( + "GET", + "/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/activity", + 200, + ), + ).toBe(true); }); it("silences successful static asset requests", () => { diff --git a/server/src/middleware/http-log-policy.ts b/server/src/middleware/http-log-policy.ts index b9d924744d6..fee44fee1af 100644 --- a/server/src/middleware/http-log-policy.ts +++ b/server/src/middleware/http-log-policy.ts @@ -2,8 +2,13 @@ const SILENCED_SUCCESS_METHODS = new Set(["GET", "HEAD"]); const SILENCED_SUCCESS_API_PATHS = [ /^\/api\/health(?:\/|$)/, + /^\/api\/companies\/[^/]+\/activity(?:\/|$)/, + /^\/api\/companies\/[^/]+\/dashboard(?:\/|$)/, + /^\/api\/companies\/[^/]+\/heartbeat-runs(?:\/|$)/, + /^\/api\/companies\/[^/]+\/issues(?:\/|$)/, /^\/api\/companies\/[^/]+\/live-runs(?:\/|$)/, /^\/api\/companies\/[^/]+\/sidebar-badges(?:\/|$)/, + /^\/api\/heartbeat-runs\/[^/]+\/log(?:\/|$)/, ]; const SILENCED_SUCCESS_STATIC_PREFIXES = [ diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index cbc72e007c6..87f3411fc6d 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1433,7 +1433,7 @@ export function agentRoutes(db: Db) { }); const telemetryClient = getTelemetryClient(); if (telemetryClient) { - trackAgentCreated(telemetryClient, { agentRole: agent.role }); + trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id }); } await applyDefaultAgentTaskAssignGrant( @@ -1521,7 +1521,7 @@ export function agentRoutes(db: Db) { }); const telemetryClient = getTelemetryClient(); if (telemetryClient) { - trackAgentCreated(telemetryClient, { agentRole: agent.role }); + trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id }); } await applyDefaultAgentTaskAssignGrant( diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 651df9c17fe..0ad65048697 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -75,6 +75,7 @@ import { import { extractSkillMentionIds } from "@paperclipai/shared"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; +const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; @@ -98,6 +99,7 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([ "opencode_local", "pi_local", ]); +const INLINE_BASE64_IMAGE_DATA_RE = /("type":"image","source":\{"type":"base64","data":")([A-Za-z0-9+/=]{1024,})(")/g; type RuntimeConfigSecretResolver = Pick< ReturnType, @@ -413,6 +415,23 @@ function appendExcerpt(prev: string, chunk: string) { return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES); } +function redactInlineBase64ImageData(chunk: string) { + return chunk.replace(INLINE_BASE64_IMAGE_DATA_RE, (_match, prefix: string, data: string, suffix: string) => + `${prefix}[omitted base64 image data: ${data.length} chars]${suffix}`, + ); +} + +export function compactRunLogChunk(chunk: string, maxChars = MAX_PERSISTED_LOG_CHUNK_CHARS) { + const normalized = redactInlineBase64ImageData(chunk); + if (normalized.length <= maxChars) return normalized; + + const headChars = Math.max(0, Math.floor(maxChars * 0.6)); + const tailChars = Math.max(0, Math.floor(maxChars * 0.25)); + const omittedChars = Math.max(0, normalized.length - headChars - tailChars); + const marker = `\n[paperclip truncated run log chunk: omitted ${omittedChars} chars]\n`; + return `${normalized.slice(0, headChars)}${marker}${normalized.slice(normalized.length - tailChars)}`; +} + function normalizeMaxConcurrentRuns(value: unknown) { const parsed = Math.floor(asNumber(value, HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT)); if (!Number.isFinite(parsed)) return HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT; @@ -2517,7 +2536,7 @@ export function heartbeatService(db: Db) { if (isFirstHeartbeat && updated) { const tc = getTelemetryClient(); - if (tc) trackAgentFirstHeartbeat(tc, { agentRole: updated.role }); + if (tc) trackAgentFirstHeartbeat(tc, { agentRole: updated.role, agentId: updated.id }); } if (updated) { @@ -3532,7 +3551,9 @@ export function heartbeatService(db: Db) { const currentUserRedactionOptions = await getCurrentUserRedactionOptions(); const onLog = async (stream: "stdout" | "stderr", chunk: string) => { - const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions); + const sanitizedChunk = compactRunLogChunk( + redactCurrentUserText(chunk, currentUserRedactionOptions), + ); if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk); if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk); const ts = new Date().toISOString(); diff --git a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx index 73f230b7e91..3d3a063463a 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx +++ b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx @@ -3,6 +3,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "../../api/client"; import { useLiveRunTranscripts } from "./useLiveRunTranscripts"; const { useQueryMock, logMock } = vi.hoisted(() => ({ @@ -188,4 +189,40 @@ describe("useLiveRunTranscripts", () => { }); container.remove(); }); + + it("stops retrying terminal runs whose persisted log never existed", async () => { + logMock.mockReset(); + logMock.mockRejectedValue(new ApiError("Run log not found", 404, { error: "Run log not found" })); + + function Harness() { + useLiveRunTranscripts({ + companyId: "company-1", + runs: [{ id: "run-404", status: "failed", adapterType: "codex_local" }], + }); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(logMock).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(logMock).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + container.remove(); + }); }); diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index ada4ea88e83..5f42a90529b 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { LiveEvent } from "@paperclipai/shared"; +import { ApiError } from "../../api/client"; import { instanceSettingsApi } from "../../api/instanceSettings"; import { heartbeatsApi } from "../../api/heartbeats"; import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters"; @@ -85,6 +86,7 @@ export function useLiveRunTranscripts({ const seenChunkKeysRef = useRef(new Set()); const pendingLogRowsByRunRef = useRef(new Map()); const logOffsetByRunRef = useRef(new Map()); + const missingTerminalLogRunIdsRef = useRef(new Set()); // Tick counter to force transcript recomputation when dynamic parser loads const [parserTick, setParserTick] = useState(0); useEffect(() => { @@ -160,6 +162,11 @@ export function useLiveRunTranscripts({ logOffsetByRunRef.current.delete(runId); } } + for (const runId of missingTerminalLogRunIdsRef.current.keys()) { + if (!knownRunIds.has(runId)) { + missingTerminalLogRunIdsRef.current.delete(runId); + } + } }, [normalizedRuns]); useEffect(() => { @@ -168,6 +175,9 @@ export function useLiveRunTranscripts({ let cancelled = false; const readRunLog = async (run: RunTranscriptSource) => { + if (missingTerminalLogRunIdsRef.current.has(run.id)) { + return; + } const offset = logOffsetByRunRef.current.get(run.id) ?? 0; try { const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES); @@ -182,8 +192,10 @@ export function useLiveRunTranscripts({ if (result.content.length > 0) { logOffsetByRunRef.current.set(run.id, offset + result.content.length); } - } catch { - // Ignore log read errors while output is initializing. + } catch (error) { + if (error instanceof ApiError && error.status === 404 && isTerminalStatus(run.status)) { + missingTerminalLogRunIdsRef.current.add(run.id); + } } finally { if (!cancelled) { setHydratedRunIds((prev) => { diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts index 627398d6869..9a092613dc2 100644 --- a/ui/src/hooks/useInboxBadge.ts +++ b/ui/src/hooks/useInboxBadge.ts @@ -20,6 +20,7 @@ import { } from "../lib/inbox"; const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done"; +const INBOX_BADGE_HEARTBEAT_RUN_LIMIT = 200; export function useDismissedInboxAlerts() { const [dismissed, setDismissed] = useState>(loadDismissedInboxAlerts); @@ -181,8 +182,8 @@ export function useInboxBadge(companyId: string | null | undefined) { const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]); const { data: heartbeatRuns = [] } = useQuery({ - queryKey: queryKeys.heartbeats(companyId!), - queryFn: () => heartbeatsApi.list(companyId!), + queryKey: [...queryKeys.heartbeats(companyId!), "limit", INBOX_BADGE_HEARTBEAT_RUN_LIMIT], + queryFn: () => heartbeatsApi.list(companyId!, undefined, INBOX_BADGE_HEARTBEAT_RUN_LIMIT), enabled: !!companyId, }); diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index bc681db468a..5119b74ef3a 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -81,8 +81,8 @@ export function Agents() { }); const { data: runs } = useQuery({ - queryKey: queryKeys.heartbeats(selectedCompanyId!), - queryFn: () => heartbeatsApi.list(selectedCompanyId!), + queryKey: [...queryKeys.liveRuns(selectedCompanyId!), "agents-page"], + queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), enabled: !!selectedCompanyId, refetchInterval: 15_000, }); diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index c30f3bb4f11..0f6ccb12e14 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -26,6 +26,8 @@ import { PageSkeleton } from "../components/PageSkeleton"; import type { Agent, Issue } from "@paperclipai/shared"; import { PluginSlotOutlet } from "@/plugins/slots"; +const DASHBOARD_HEARTBEAT_RUN_LIMIT = 100; + function getRecentIssues(issues: Issue[]): Issue[] { return [...issues] .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); @@ -75,8 +77,8 @@ export function Dashboard() { }); const { data: runs } = useQuery({ - queryKey: queryKeys.heartbeats(selectedCompanyId!), - queryFn: () => heartbeatsApi.list(selectedCompanyId!), + queryKey: [...queryKeys.heartbeats(selectedCompanyId!), "limit", DASHBOARD_HEARTBEAT_RUN_LIMIT], + queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, DASHBOARD_HEARTBEAT_RUN_LIMIT), enabled: !!selectedCompanyId, }); diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 6786e218ff2..b8ce6d7ee11 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -87,6 +87,8 @@ import { Search, ListTree, } from "lucide-react"; + +const INBOX_HEARTBEAT_RUN_LIMIT = 200; import { Input } from "@/components/ui/input"; import { PageTabBar } from "../components/PageTabBar"; import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; @@ -788,8 +790,8 @@ export function Inbox() { }); const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({ - queryKey: queryKeys.heartbeats(selectedCompanyId!), - queryFn: () => heartbeatsApi.list(selectedCompanyId!), + queryKey: [...queryKeys.heartbeats(selectedCompanyId!), "limit", INBOX_HEARTBEAT_RUN_LIMIT], + queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT), enabled: !!selectedCompanyId, });