diff --git a/server/src/__tests__/heartbeat-run-log.test.ts b/server/src/__tests__/heartbeat-run-log.test.ts index 9eb6eda9c79..df0f9f3cfbb 100644 --- a/server/src/__tests__/heartbeat-run-log.test.ts +++ b/server/src/__tests__/heartbeat-run-log.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { compactRunLogChunk } from "../services/heartbeat.js"; +import type { Db } from "@paperclipai/db"; +import { compactRunLogChunk, heartbeatService } from "../services/heartbeat.js"; describe("compactRunLogChunk", () => { it("redacts inline base64 image data from structured log chunks", () => { @@ -39,3 +40,24 @@ describe("compactRunLogChunk", () => { expect(compacted).not.toContain("paperclip-flag-secret"); }); }); + +describe("heartbeat run log reads", () => { + it("returns a pending empty payload when an existing run has no initialized log yet", async () => { + const service = heartbeatService({} as Db); + + await expect( + service.readLog({ + id: "run-pending-log", + companyId: "company-1", + logStore: null, + logRef: null, + }), + ).resolves.toEqual({ + runId: "run-pending-log", + store: null, + logRef: null, + content: "", + pending: true, + }); + }); +}); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 09a1e5fe7cb..b18f8b9f9f4 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -10116,7 +10116,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const run = typeof runOrLookup === "string" ? await getRunLogAccess(runOrLookup) : runOrLookup; const runId = typeof runOrLookup === "string" ? runOrLookup : runOrLookup.id; if (!run) throw notFound("Heartbeat run not found"); - if (!run.logStore || !run.logRef) throw notFound("Run log not found"); + if (!run.logStore || !run.logRef) { + return { + runId, + store: run.logStore, + logRef: run.logRef, + content: "", + pending: true, + }; + } const result = await runLogStore.read( { diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index fbaad815d7e..0f4c4f0c361 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -84,7 +84,7 @@ export const heartbeatsApi = { `/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`, ), log: (runId: string, offset = 0, limitBytes = 256000) => - api.get<{ runId: string; store: string; logRef: string; content: string; nextOffset?: number }>( + api.get<{ runId: string; store: string | null; logRef: string | null; content: string; nextOffset?: number; pending?: boolean }>( `/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`, ), workspaceOperations: (runId: string) => diff --git a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx index 025d2f0ed0c..2422b1586e7 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx +++ b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx @@ -81,6 +81,7 @@ describe("useLiveRunTranscripts", () => { }); afterEach(() => { + vi.useRealTimers(); globalThis.WebSocket = OriginalWebSocket; }); @@ -229,6 +230,44 @@ describe("useLiveRunTranscripts", () => { container.remove(); }); + it("stops polling active runs after a persisted-log 404", async () => { + vi.useFakeTimers(); + 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-stale", status: "running", adapterType: "codex_local" }], + logPollIntervalMs: 10, + }); + 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 () => { + vi.advanceTimersByTime(30); + await Promise.resolve(); + }); + + expect(logMock).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + it("can hydrate active runs without opening the live event socket", async () => { function Harness() { useLiveRunTranscripts({ diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index 6c001e2fae4..a19f76a7099 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -113,7 +113,7 @@ export function useLiveRunTranscripts({ const seenChunkKeysRef = useRef(new Set()); const pendingLogRowsByRunRef = useRef(new Map()); const logOffsetByRunRef = useRef(new Map()); - const missingTerminalLogRunIdsRef = useRef(new Set()); + const missingLogRunIdsRef = useRef(new Set()); const transcriptCacheRef = useRef(new Map { - if (missingTerminalLogRunIdsRef.current.has(run.id)) { + if (missingLogRunIdsRef.current.has(run.id)) { return; } const offset = logOffsetByRunRef.current.get(run.id) ?? resolveInitialLogOffset(run, logReadLimitBytes); @@ -232,8 +232,8 @@ export function useLiveRunTranscripts({ logOffsetByRunRef.current.set(run.id, offset + result.content.length); } } catch (error) { - if (error instanceof ApiError && error.status === 404 && isTerminalStatus(run.status)) { - missingTerminalLogRunIdsRef.current.add(run.id); + if (error instanceof ApiError && error.status === 404) { + missingLogRunIdsRef.current.add(run.id); } } finally { if (!cancelled) {