mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Its web UI keeps live views (issue threads, run transcripts, dashboard) fresh via React Query polling plus a live-events websocket, coordinated across tabs with a `BroadcastChannel` layer > - Long-lived tabs viewing live agent runs grew to multi-GB memory footprints while their JS heap stayed ~60–200 MB — so the memory is off-heap (Blink/native + committed allocator arenas), not a classic JS leak > - Live profiling of a reproduced 2.7 GB / 66 MB tab showed 15–30% idle CPU, ~3 fetches/sec across overlapping poll loops, and ~8 `setInterval` create/clear cycles per second whose rate grew ~7× as the tab aged — relentless allocation churn that inflates committed memory the OS never reclaims, amplified across tabs by the cross-tab fan-out > - This pull request cuts that churn at its four largest sources (invalidation storm, per-instance 1 s timers, redundant polling, unbounded streamed-run set) > - The benefit is that idle tabs do far less periodic work, so their off-heap footprint stops ballooning over a long session ## Linked Issues or Issue Description No public GitHub issue exists; describing inline per CONTRIBUTING.md → "Link Issues or Describe Them In-PR", following the bug report template. **What happened?** Browser tabs viewing live agent runs grew to 8–16 GB memory footprint over a long session (multiple tabs open), while each tab's "live" JS heap stayed only ~150–250 MB. Every idle tab also burned 15–30% CPU. Tabs eventually approached the ~4 GB V8 heap ceiling / OS pressure and could crash. **Expected behavior** Tabs viewing live runs should hold a bounded footprint and do minimal work while idle, regardless of how long they stay open or how many tabs are open. **Steps to reproduce** Open several issue/run tabs that have agents actively streaming and leave them open for a while. Watch Chrome's Task Manager: Memory Footprint climbs into the GBs while "JavaScript Memory" stays small, and CPU stays high on idle tabs. Reproduced in ~90 minutes: a tab reached 2.7 GB footprint on a 66 MB JS heap, and the per-second timer-churn rate was ~7× higher on a 90-minute-old tab than a fresh one. **Paperclip version or commit** Branch `fix/live-updates-churn`, off `master`. **Deployment mode** Local dev (`pnpm dev`), web UI. Not adapter-specific — core UI live-updates plumbing (observed with `claude_local` / `codex_local` runs). ## What Changed - **`ui/src/lib/query-invalidation-batcher.ts` (new)** — `createInvalidationBatcher` throttles + de-dupes React Query invalidations into one flush per ~300 ms, and `createCoalescingQueryClient` wraps the client via a `Proxy` so only `invalidateQueries` is batched (optimistic `setQueryData` writes stay immediate). Wired into `LiveUpdatesProvider`, which previously invalidated synchronously on every websocket event. - **`ui/src/hooks/useSecondTick.ts` (new)** — one shared, ref-counted, page-wide 1 s ticker. `useLiveElapsed` in `IssueChatThread` now uses it instead of a per-instance `setInterval` that forced a full-thread re-render every second per live element. - **`ui/src/components/transcript/useLiveRunTranscripts.ts`** — when the realtime websocket is enabled, the recurring log poll backs off to a 30 s safety-net cadence instead of polling every 2 s on top of the live stream. Added a marker for the durable poll→push rearchitecture. - **`ui/src/lib/issueChatTranscriptRuns.ts`** — `resolveIssueChatTranscriptRuns` now caps the streamed run set (live/active runs always kept; most-recent linked runs fill up to 20) so a large run history can't open a live-transcript poll per historical run. - **`ui/src/main.tsx`** — explicit `gcTime` so cross-tab-published cache entries for unobserved resources are collected promptly. - Tests for the batcher, shared ticker, and run cap. ## Verification - `vitest`: new suites `query-invalidation-batcher.test.ts` (batcher collapses 20 invalidations → 1 flush; keeps distinct keys/variants; dispose cancels; proxy passes non-invalidate methods through), `useSecondTick.test.tsx` (single ref-counted timer, stops when idle), `issueChatTranscriptRuns.test.ts` (cap keeps newest + live). All pass. - Existing affected suites pass: `LiveUpdatesProvider` (23), `IssueChatThread` (), `useLiveRunTranscripts`, `AgentDetail.instructions` — 109 tests across affected files. - `tsc -b` clean. - Behavior confirmed by live profiling before the change (2.7 GB / 66 MB tab, ~8 interval churns/sec growing 7× with age). Runtime churn reduction should be re-measured against a rebuilt bundle with the same instrumentation. ## Risks Low-to-moderate; all changes reduce work rather than add features. - **Invalidation batching** delays live-driven refetches by up to ~300 ms. Optimistic `setQueryData` writes (e.g. the visible issue's new comment) remain immediate, so foreground updates still feel instant; only the safety-net refetch is throttled. Non-live invalidations (user actions, mutations) are unaffected — they use the real client. - **Poll back-off** relies on the websocket as the live source when realtime is enabled; a 30 s fallback poll still covers gaps/reconnects (both the transcript hook and `LiveUpdatesProvider` also auto-reconnect). - **Run cap (20)** means an issue with a very large run history streams live transcripts only for its live/active + 20 most-recent runs; older runs still open normally via their run pages. - Downstream test fallout (timing-sensitive tests around invalidation/polling) may need adjustment — flagged intentionally for follow-up. Durable follow-up (out of scope, marked in code): replace transcript/run polling with server push (SSE/websocket deltas) so idle tabs do no periodic work at all. ## Model Used - **Provider:** Anthropic, via the Claude Code CLI. - **Model:** Claude Opus 4.8 (`claude-opus-4-8`). - **Reasoning mode:** Extended thinking enabled. - **Capabilities used:** tool use (shell, file editing), sub-agent fan-out for codebase analysis, and the Chrome DevTools MCP to reproduce and profile the memory/CPU churn on a live instance. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work (a bug/perf fix, not planned core feature work) - [x] I have searched GitHub for duplicate or related PRs and linked them above (none found) - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have considered and documented any risks above - [ ] I have updated relevant documentation to reflect my changes (N/A — no user-facing docs; rationale documented inline) - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
506 lines
18 KiB
TypeScript
506 lines
18 KiB
TypeScript
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";
|
|
import { queryKeys } from "../../lib/queryKeys";
|
|
import { buildSameOriginWebSocketUrl } from "../../lib/websocket-url";
|
|
|
|
// TODO(perf): this whole hook polls the log/runs endpoints on an interval. The
|
|
// durable fix is server push (SSE/websocket) for transcript deltas so idle tabs
|
|
// do no periodic work at all; the constants below only reduce the churn of the
|
|
// current polling approach.
|
|
const LOG_POLL_INTERVAL_MS = 2000;
|
|
const LOG_READ_LIMIT_BYTES = 256_000;
|
|
// When realtime websocket updates are enabled, the frequent log poll is
|
|
// redundant with the live stream; keep only a slow safety-net poll to cover
|
|
// gaps and reconnects instead of polling every couple of seconds.
|
|
const REALTIME_FALLBACK_POLL_INTERVAL_MS = 30_000;
|
|
const EMPTY_RUN_LOG_CHUNKS: RunLogChunk[] = [];
|
|
|
|
export interface RunTranscriptSource {
|
|
id: string;
|
|
status: string;
|
|
adapterType: string;
|
|
hasStoredOutput?: boolean;
|
|
logBytes?: number | null;
|
|
lastOutputBytes?: number | null;
|
|
}
|
|
|
|
interface UseLiveRunTranscriptsOptions {
|
|
runs: RunTranscriptSource[];
|
|
companyId?: string | null;
|
|
maxChunksPerRun?: number;
|
|
logPollIntervalMs?: number;
|
|
logReadLimitBytes?: number;
|
|
enableRealtimeUpdates?: boolean;
|
|
}
|
|
|
|
function readString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
function isTerminalStatus(status: string): boolean {
|
|
return status === "failed" || status === "timed_out" || status === "cancelled" || status === "interrupted" || status === "succeeded";
|
|
}
|
|
|
|
function runKnownLogBytes(run: RunTranscriptSource): number | null {
|
|
const bytes = run.status === "queued"
|
|
? run.logBytes
|
|
: run.lastOutputBytes ?? run.logBytes;
|
|
return typeof bytes === "number" && Number.isFinite(bytes) && bytes > 0 ? bytes : null;
|
|
}
|
|
|
|
export function resolveInitialLogOffset(run: RunTranscriptSource, limitBytes: number): number {
|
|
const knownBytes = runKnownLogBytes(run);
|
|
if (knownBytes === null) return 0;
|
|
return Math.max(0, knownBytes - Math.max(0, limitBytes));
|
|
}
|
|
|
|
function readChunkSeq(value: unknown): number | undefined {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
}
|
|
|
|
function isStructuredStreamingTextDelta(chunk: string) {
|
|
return /"type"\s*:\s*"(?:acpx\.text_delta|text)"/.test(chunk);
|
|
}
|
|
|
|
function parsePersistedLogContent(
|
|
runId: string,
|
|
content: string,
|
|
pendingByRun: Map<string, string>,
|
|
): Array<RunLogChunk & { dedupeKey: string }> {
|
|
if (!content) return [];
|
|
|
|
const pendingKey = `${runId}:records`;
|
|
const combined = `${pendingByRun.get(pendingKey) ?? ""}${content}`;
|
|
const split = combined.split("\n");
|
|
pendingByRun.set(pendingKey, split.pop() ?? "");
|
|
|
|
const parsed: Array<RunLogChunk & { dedupeKey: string }> = [];
|
|
for (const line of split) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
try {
|
|
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown; seq?: unknown };
|
|
const stream = raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
|
|
const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
|
|
const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
|
|
if (!chunk) continue;
|
|
parsed.push({
|
|
ts,
|
|
stream,
|
|
chunk,
|
|
seq: readChunkSeq(raw.seq),
|
|
dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}`,
|
|
});
|
|
} catch {
|
|
// Ignore malformed log rows.
|
|
}
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
export function useLiveRunTranscripts({
|
|
runs,
|
|
companyId,
|
|
maxChunksPerRun = 200,
|
|
logPollIntervalMs = LOG_POLL_INTERVAL_MS,
|
|
logReadLimitBytes = LOG_READ_LIMIT_BYTES,
|
|
enableRealtimeUpdates = true,
|
|
}: UseLiveRunTranscriptsOptions) {
|
|
const runsKey = useMemo(
|
|
() =>
|
|
runs
|
|
.map((run) => {
|
|
const logBytes = typeof run.logBytes === "number" ? run.logBytes : "";
|
|
const lastOutputBytes = typeof run.lastOutputBytes === "number" ? run.lastOutputBytes : "";
|
|
return `${run.id}:${run.status}:${run.adapterType}:${run.hasStoredOutput === true ? "1" : "0"}:${logBytes}:${lastOutputBytes}`;
|
|
})
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(","),
|
|
[runs],
|
|
);
|
|
const normalizedRuns = useMemo(() => runs.map((run) => ({ ...run })), [runsKey]);
|
|
const [chunksByRun, setChunksByRun] = useState<Map<string, RunLogChunk[]>>(new Map());
|
|
const [hydratedRunIds, setHydratedRunIds] = useState<Set<string>>(new Set());
|
|
const seenChunkKeysRef = useRef(new Set<string>());
|
|
// Highest sequenced chunk trimmed out of a run's retained window; older
|
|
// records re-delivered by the other transport are dropped instead of being
|
|
// re-inserted ahead of newer output.
|
|
const trimmedSeqFloorByRunRef = useRef(new Map<string, number>());
|
|
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
|
|
const logOffsetByRunRef = useRef(new Map<string, number>());
|
|
const missingTerminalLogRunIdsRef = useRef(new Set<string>());
|
|
const transcriptCacheRef = useRef(new Map<string, {
|
|
adapterType: string;
|
|
chunks: RunLogChunk[];
|
|
censorUsernameInLogs: boolean;
|
|
parserTick: number;
|
|
transcript: TranscriptEntry[];
|
|
}>());
|
|
// Tick counter to force transcript recomputation when dynamic parser loads
|
|
const [parserTick, setParserTick] = useState(0);
|
|
useEffect(() => {
|
|
return onAdapterChange(() => setParserTick((t) => t + 1));
|
|
}, []);
|
|
const { data: generalSettings } = useQuery({
|
|
queryKey: queryKeys.instance.generalSettings,
|
|
queryFn: () => instanceSettingsApi.getGeneral(),
|
|
});
|
|
|
|
const runById = useMemo(() => new Map(normalizedRuns.map((run) => [run.id, run])), [normalizedRuns]);
|
|
const activeRunIds = useMemo(
|
|
() => new Set(normalizedRuns.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)),
|
|
[normalizedRuns],
|
|
);
|
|
const runIdsKey = useMemo(
|
|
() => normalizedRuns.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","),
|
|
[normalizedRuns],
|
|
);
|
|
|
|
const appendChunks = (runId: string, chunks: Array<RunLogChunk & { dedupeKey: string }>) => {
|
|
if (chunks.length === 0) return;
|
|
setChunksByRun((prev) => {
|
|
const next = new Map(prev);
|
|
const existing = [...(next.get(runId) ?? [])];
|
|
let changed = false;
|
|
|
|
for (const chunk of chunks) {
|
|
// Sequenced log chunks (persisted rows and websocket log payloads)
|
|
// dedupe and order by the server-assigned monotonic seq. Identical
|
|
// token deltas from ACP-style adapters often share the same
|
|
// millisecond ts and chunk text, so content-based keys drop real
|
|
// output; seq keeps every record and restores emit order when the
|
|
// websocket and the poller interleave.
|
|
if (typeof chunk.seq === "number") {
|
|
const seqFloor = trimmedSeqFloorByRunRef.current.get(runId) ?? 0;
|
|
if (chunk.seq <= seqFloor) continue;
|
|
const duplicateAt = existing.findIndex((item) => item.seq === chunk.seq);
|
|
if (duplicateAt !== -1) {
|
|
// Same record arrived via the other delivery path. Prefer the
|
|
// longer payload: websocket chunks may be tail-truncated while
|
|
// the persisted row is complete.
|
|
if (chunk.chunk.length > existing[duplicateAt]!.chunk.length) {
|
|
existing[duplicateAt] = { ts: chunk.ts, stream: chunk.stream, chunk: chunk.chunk, seq: chunk.seq };
|
|
changed = true;
|
|
}
|
|
continue;
|
|
}
|
|
// Insert in seq order relative to the trailing sequenced chunks so
|
|
// late-arriving records from the slower delivery path land where
|
|
// they were emitted. Unsequenced chunks act as an ordering barrier.
|
|
let insertAt = existing.length;
|
|
while (insertAt > 0) {
|
|
const prior = existing[insertAt - 1]!;
|
|
if (typeof prior.seq !== "number" || prior.seq < chunk.seq) break;
|
|
insertAt -= 1;
|
|
}
|
|
existing.splice(insertAt, 0, { ts: chunk.ts, stream: chunk.stream, chunk: chunk.chunk, seq: chunk.seq });
|
|
changed = true;
|
|
continue;
|
|
}
|
|
|
|
if (!isStructuredStreamingTextDelta(chunk.chunk)) {
|
|
if (seenChunkKeysRef.current.has(chunk.dedupeKey)) continue;
|
|
seenChunkKeysRef.current.add(chunk.dedupeKey);
|
|
}
|
|
existing.push({ ts: chunk.ts, stream: chunk.stream, chunk: chunk.chunk });
|
|
changed = true;
|
|
}
|
|
|
|
if (!changed) return prev;
|
|
if (seenChunkKeysRef.current.size > 12000) {
|
|
seenChunkKeysRef.current.clear();
|
|
}
|
|
if (existing.length > maxChunksPerRun) {
|
|
const trimmed = existing.splice(0, existing.length - maxChunksPerRun);
|
|
let seqFloor = trimmedSeqFloorByRunRef.current.get(runId) ?? 0;
|
|
for (const item of trimmed) {
|
|
if (typeof item.seq === "number" && item.seq > seqFloor) seqFloor = item.seq;
|
|
}
|
|
if (seqFloor > 0) trimmedSeqFloorByRunRef.current.set(runId, seqFloor);
|
|
}
|
|
next.set(runId, existing);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
const knownRunIds = new Set(normalizedRuns.map((run) => run.id));
|
|
setChunksByRun((prev) => {
|
|
const next = new Map<string, RunLogChunk[]>();
|
|
for (const [runId, chunks] of prev) {
|
|
if (knownRunIds.has(runId)) {
|
|
next.set(runId, chunks);
|
|
}
|
|
}
|
|
return next.size === prev.size ? prev : next;
|
|
});
|
|
setHydratedRunIds((prev) => {
|
|
const next = new Set<string>();
|
|
for (const runId of prev) {
|
|
if (knownRunIds.has(runId)) {
|
|
next.add(runId);
|
|
}
|
|
}
|
|
return next.size === prev.size ? prev : next;
|
|
});
|
|
|
|
for (const key of pendingLogRowsByRunRef.current.keys()) {
|
|
const runId = key.replace(/:records$/, "");
|
|
if (!knownRunIds.has(runId)) {
|
|
pendingLogRowsByRunRef.current.delete(key);
|
|
}
|
|
}
|
|
for (const runId of logOffsetByRunRef.current.keys()) {
|
|
if (!knownRunIds.has(runId)) {
|
|
logOffsetByRunRef.current.delete(runId);
|
|
}
|
|
}
|
|
for (const runId of trimmedSeqFloorByRunRef.current.keys()) {
|
|
if (!knownRunIds.has(runId)) {
|
|
trimmedSeqFloorByRunRef.current.delete(runId);
|
|
}
|
|
}
|
|
for (const runId of missingTerminalLogRunIdsRef.current.keys()) {
|
|
if (!knownRunIds.has(runId)) {
|
|
missingTerminalLogRunIdsRef.current.delete(runId);
|
|
}
|
|
}
|
|
for (const runId of transcriptCacheRef.current.keys()) {
|
|
if (!knownRunIds.has(runId)) {
|
|
transcriptCacheRef.current.delete(runId);
|
|
}
|
|
}
|
|
}, [normalizedRuns]);
|
|
|
|
useEffect(() => {
|
|
if (normalizedRuns.length === 0) return;
|
|
|
|
let cancelled = false;
|
|
|
|
const readRunLog = async (run: RunTranscriptSource) => {
|
|
if (missingTerminalLogRunIdsRef.current.has(run.id)) {
|
|
return;
|
|
}
|
|
const offset = logOffsetByRunRef.current.get(run.id) ?? resolveInitialLogOffset(run, logReadLimitBytes);
|
|
try {
|
|
const result = await heartbeatsApi.log(run.id, offset, logReadLimitBytes);
|
|
if (cancelled) return;
|
|
|
|
appendChunks(run.id, parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current));
|
|
|
|
if (result.nextOffset !== undefined) {
|
|
logOffsetByRunRef.current.set(run.id, result.nextOffset);
|
|
return;
|
|
}
|
|
if (result.content.length > 0) {
|
|
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);
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setHydratedRunIds((prev) => {
|
|
if (prev.has(run.id)) return prev;
|
|
const next = new Set(prev);
|
|
next.add(run.id);
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const readAll = async () => {
|
|
await Promise.all(normalizedRuns.map((run) => readRunLog(run)));
|
|
};
|
|
|
|
void readAll();
|
|
const activeRuns = normalizedRuns.filter((run) => !isTerminalStatus(run.status));
|
|
// The realtime websocket is the primary live source when enabled, so the
|
|
// recurring poll only needs to run as a slow fallback rather than doubling
|
|
// the live update work every couple of seconds.
|
|
const effectivePollMs = enableRealtimeUpdates
|
|
? Math.max(logPollIntervalMs, REALTIME_FALLBACK_POLL_INTERVAL_MS)
|
|
: logPollIntervalMs;
|
|
const interval = activeRuns.length > 0 && effectivePollMs > 0
|
|
? window.setInterval(() => {
|
|
void Promise.all(activeRuns.map((run) => readRunLog(run)));
|
|
}, effectivePollMs)
|
|
: null;
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (interval !== null) window.clearInterval(interval);
|
|
};
|
|
}, [enableRealtimeUpdates, logPollIntervalMs, logReadLimitBytes, normalizedRuns, runIdsKey]);
|
|
|
|
useEffect(() => {
|
|
if (!enableRealtimeUpdates) return;
|
|
if (!companyId || activeRunIds.size === 0) return;
|
|
|
|
let closed = false;
|
|
let reconnectTimer: number | null = null;
|
|
let socket: WebSocket | null = null;
|
|
|
|
const scheduleReconnect = () => {
|
|
if (closed) return;
|
|
reconnectTimer = window.setTimeout(connect, 1500);
|
|
};
|
|
|
|
const connect = () => {
|
|
if (closed) return;
|
|
const url = buildSameOriginWebSocketUrl(
|
|
`/api/companies/${encodeURIComponent(companyId)}/events/ws`,
|
|
);
|
|
socket = new WebSocket(url);
|
|
|
|
socket.onmessage = (message) => {
|
|
const raw = typeof message.data === "string" ? message.data : "";
|
|
if (!raw) return;
|
|
|
|
let event: LiveEvent;
|
|
try {
|
|
event = JSON.parse(raw) as LiveEvent;
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (event.companyId !== companyId) return;
|
|
const payload = event.payload ?? {};
|
|
const runId = readString(payload["runId"]);
|
|
if (!runId || !activeRunIds.has(runId)) return;
|
|
if (!runById.has(runId)) return;
|
|
|
|
if (event.type === "heartbeat.run.log") {
|
|
const chunk = readString(payload["chunk"]);
|
|
if (!chunk) return;
|
|
const ts = readString(payload["ts"]) ?? event.createdAt;
|
|
const stream =
|
|
readString(payload["stream"]) === "stderr"
|
|
? "stderr"
|
|
: readString(payload["stream"]) === "system"
|
|
? "system"
|
|
: "stdout";
|
|
appendChunks(runId, [{
|
|
ts,
|
|
stream,
|
|
chunk,
|
|
seq: readChunkSeq(payload["seq"]),
|
|
dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}`,
|
|
}]);
|
|
return;
|
|
}
|
|
|
|
if (event.type === "heartbeat.run.event") {
|
|
const seq = typeof payload["seq"] === "number" ? payload["seq"] : null;
|
|
const eventType = readString(payload["eventType"]) ?? "event";
|
|
const messageText = readString(payload["message"]) ?? eventType;
|
|
appendChunks(runId, [{
|
|
ts: event.createdAt,
|
|
stream: eventType === "error" ? "stderr" : "system",
|
|
chunk: messageText,
|
|
dedupeKey: `socket:event:${runId}:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`,
|
|
}]);
|
|
return;
|
|
}
|
|
|
|
if (event.type === "heartbeat.run.status") {
|
|
const status = readString(payload["status"]) ?? "updated";
|
|
appendChunks(runId, [{
|
|
ts: event.createdAt,
|
|
stream: isTerminalStatus(status) && status !== "succeeded" ? "stderr" : "system",
|
|
chunk: `run ${status}`,
|
|
dedupeKey: `socket:status:${runId}:${status}:${readString(payload["finishedAt"]) ?? ""}`,
|
|
}]);
|
|
}
|
|
};
|
|
|
|
socket.onerror = () => {
|
|
socket?.close();
|
|
};
|
|
|
|
socket.onclose = () => {
|
|
scheduleReconnect();
|
|
};
|
|
};
|
|
|
|
connect();
|
|
|
|
return () => {
|
|
closed = true;
|
|
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
|
|
if (socket) {
|
|
socket.onmessage = null;
|
|
socket.onerror = null;
|
|
socket.onclose = null;
|
|
if (socket.readyState === WebSocket.CONNECTING) {
|
|
// Defer the close until the handshake completes so the browser
|
|
// does not emit a noisy "closed before the connection is established"
|
|
// warning during rapid run teardown.
|
|
socket.onopen = () => {
|
|
socket?.close(1000, "live_run_transcripts_unmount");
|
|
};
|
|
} else if (socket.readyState === WebSocket.OPEN) {
|
|
socket.close(1000, "live_run_transcripts_unmount");
|
|
}
|
|
}
|
|
};
|
|
}, [activeRunIds, companyId, enableRealtimeUpdates, runById]);
|
|
|
|
const transcriptByRun = useMemo(() => {
|
|
const next = new Map<string, TranscriptEntry[]>();
|
|
const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true;
|
|
const cache = transcriptCacheRef.current;
|
|
const currentRunIds = new Set<string>();
|
|
for (const run of normalizedRuns) {
|
|
currentRunIds.add(run.id);
|
|
const chunks = chunksByRun.get(run.id) ?? EMPTY_RUN_LOG_CHUNKS;
|
|
const cached = cache.get(run.id);
|
|
if (
|
|
cached &&
|
|
cached.adapterType === run.adapterType &&
|
|
cached.chunks === chunks &&
|
|
cached.censorUsernameInLogs === censorUsernameInLogs &&
|
|
cached.parserTick === parserTick
|
|
) {
|
|
next.set(run.id, cached.transcript);
|
|
continue;
|
|
}
|
|
|
|
const adapter = getUIAdapter(run.adapterType);
|
|
const transcript = buildTranscript(chunks, adapter, {
|
|
censorUsernameInLogs,
|
|
});
|
|
cache.set(run.id, {
|
|
adapterType: run.adapterType,
|
|
chunks,
|
|
censorUsernameInLogs,
|
|
parserTick,
|
|
transcript,
|
|
});
|
|
next.set(run.id, transcript);
|
|
}
|
|
for (const runId of cache.keys()) {
|
|
if (!currentRunIds.has(runId)) {
|
|
cache.delete(runId);
|
|
}
|
|
}
|
|
return next;
|
|
}, [chunksByRun, generalSettings?.censorUsernameInLogs, normalizedRuns, parserTick]);
|
|
|
|
return {
|
|
transcriptByRun,
|
|
isInitialHydrating: normalizedRuns.some((run) => !hydratedRunIds.has(run.id)),
|
|
hasOutputForRun(runId: string) {
|
|
return (chunksByRun.get(runId)?.length ?? 0) > 0 || runById.get(runId)?.hasStoredOutput === true;
|
|
},
|
|
};
|
|
}
|