mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 23:20:53 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Each task page shows the instruction and saved conversation. > - Cold opens show recent content before older history. > - The page used any linked run as evidence of older messages. > - A task's first run therefore showed an empty More messages control. > - This PR shows the control only when the preview omits comments or runs. > - Fresh tasks stay clear, and older task history remains available. ## Linked Issues or Issue Description Refs: #17593. **What happened?** A fresh task with only its first run displayed More messages, even though there was no earlier history to load. **Expected behavior** Show More messages only when older comments or runs are omitted. Runs observed while the page stays open must not create a false disclosure. The output control also uses actual skipped bytes, so a growing run does not gain an empty output control. **Steps to reproduce** 1. Create a task and let its first run start or finish. 2. Open that task with a fresh application cache. 3. Observe the empty More messages control below its instruction. ## What Changed - Keep omitted-run evidence with the cached recent run window. - Use omitted comments or runs to decide whether to show More messages. - Preserve run retention, reconnect reconciliation, and expansion of real history. - Offer More run output when a single legacy log exceeds the compact byte window. - Keep first-run native diagnostics accessible through More task actions → Show task activity. - Add regression coverage and update the task feature map. ## Verification - Passed the existing 9,168-test UI suite across 794 files, then all 504 focused task-detail, history-window, real thread, and log-transport tests after adding the activity-menu regression. - Passed workspace build, workspace typechecks, token gates, and map checks. - Reproduced the bug in the earlier UI using a disposable local task. The corrected UI hides the control with zero runs or only the first run. - In the local browser, expanded a long task to all 80 comments. The latest answer appeared once and the disclosure disappeared. No console warnings or errors occurred in that journey. - Expanded a saved 111 KB legacy log through More run output and opened its earlier activity in the local browser. - On a single saved native run, verified no More messages control. Used Show task activity to reveal its earlier tool output; the latest answer remained visible exactly once, with no console warnings or errors. - The first broad local run stopped because the fresh dependency install lacked PostgreSQL library symlinks. The normal package setup repaired that environment issue. - The next broad run hit one workspace-runtime timeout. Its focused rerun passed with unchanged server source; the original failure remains recorded. Stopped the already-failed broad runner after the previous revision passed full PR CI. All final-head build, typecheck, test, and policy CI jobs passed. Both automated reviewers scored the final head 5/5, with no unresolved threads. The separate Entire Gates check is pending because the PR has no active assessed Change for its current comparison. The installed Entire CLI reports no linked project trail and exposes no Change-assessment command; no gate or repository settings were altered. Browser verification used this branch's UI with the existing disposable test-drive API. No agent execution was started. ## Risks Low risk. This changes the recent run cache shape and the disclosure condition. The full run cache remains an array. Tests cover first-run states, observed new runs, omitted older runs, expansion, and reconnect reconciliation. No schema or API changes. A separate run-output control preserves access to a single truncated legacy log. The task menu preserves first-run native activity access without implying older messages exist. ## Model Used OpenAI GPT-6 through Codex, with code execution and browser tools. A more specific model ID and context-window size are not exposed in this session. ## 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 - [x] I have searched GitHub for duplicate or related PRs and linked them above - [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 updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
620 lines
24 KiB
TypeScript
620 lines
24 KiB
TypeScript
import { usePageVisibility } from "../../lib/page-visibility";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { readTranscriptRequest } from "./read-transcript-request";
|
|
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";
|
|
import { tryCreateWebSocket } from "../../lib/websocket";
|
|
import {
|
|
mergeRunLogChunks,
|
|
parsePersistedLogContent,
|
|
readChunkSeq,
|
|
type ChunkRetentionBudget,
|
|
} from "../../lib/run-log-chunks";
|
|
|
|
// 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 SOCKET_CONNECTING = 0;
|
|
const SOCKET_OPEN = 1;
|
|
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[] = [];
|
|
// Retained transcript payload budget for full task views. A byte budget (rather
|
|
// than a tiny chunk count) keeps the whole streamed scrollback intact — a
|
|
// delta-streaming run emits thousands of one-token chunks in seconds, and the
|
|
// old 200-chunk cap discarded just-rendered messages off the top irreversibly.
|
|
// If a run genuinely exceeds this, the oldest output collapses behind a visible
|
|
// marker instead of vanishing (see `applyRetentionBudget`).
|
|
const TASK_VIEW_MAX_BYTES_PER_RUN = 2_000_000;
|
|
// Grace period before an accumulated transcript buffer is pruned for a run that
|
|
// has vanished from the `runs` list. The parent refetches runs on its own
|
|
// interval, and a single transient empty/errored poll would otherwise wipe the
|
|
// buffer — forcing a rehydration that skips to the last `LOG_READ_LIMIT_BYTES`
|
|
// and silently drops already-rendered scrollback (PAP-462 B3). A run that is
|
|
// genuinely gone stays absent past this window and is then pruned as before.
|
|
const RUN_ABSENCE_PRUNE_GRACE_MS = 20_000;
|
|
|
|
export interface RunTranscriptSource {
|
|
createdAt?: string | Date;
|
|
id: string;
|
|
status: string;
|
|
adapterType: string;
|
|
runtimeMode?: "legacy" | "native";
|
|
hasStoredOutput?: boolean;
|
|
logBytes?: number | null;
|
|
lastOutputBytes?: number | null;
|
|
}
|
|
|
|
interface UseLiveRunTranscriptsOptions {
|
|
runs: RunTranscriptSource[];
|
|
companyId?: string | null;
|
|
/**
|
|
* Compact chunk-count cap for ticker-style consumers (dashboard). When set,
|
|
* trimming is silent — the historical behavior. Full task views omit this and
|
|
* use the byte budget below instead.
|
|
*/
|
|
maxChunksPerRun?: number;
|
|
maxBytesPerRun?: 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 canReadPersistedLog(run: RunTranscriptSource): boolean {
|
|
return run.status === "running" || isTerminalStatus(run.status);
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
export function useLiveRunTranscripts({
|
|
runs,
|
|
companyId,
|
|
maxChunksPerRun,
|
|
maxBytesPerRun = TASK_VIEW_MAX_BYTES_PER_RUN,
|
|
logPollIntervalMs = LOG_POLL_INTERVAL_MS,
|
|
logReadLimitBytes = LOG_READ_LIMIT_BYTES,
|
|
enableRealtimeUpdates = true,
|
|
}: UseLiveRunTranscriptsOptions) {
|
|
// Ticker consumers opt into the silent chunk-count cap; full task views use a
|
|
// byte budget that collapses (not discards) the oldest output when exceeded.
|
|
const { visible } = usePageVisibility();
|
|
const retentionBudget: ChunkRetentionBudget = useMemo(
|
|
() =>
|
|
typeof maxChunksPerRun === "number"
|
|
? { maxChunks: maxChunksPerRun }
|
|
: { maxBytes: maxBytesPerRun, collapseTrimmed: true },
|
|
[maxChunksPerRun, maxBytesPerRun],
|
|
);
|
|
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 [errorsByRun, setErrorsByRun] = useState<ReadonlyMap<string, Error>>(new Map());
|
|
const [retryGeneration, setRetryGeneration] = useState(0);
|
|
const retry = useCallback(() => {
|
|
missingTerminalLogRunIdsRef.current.clear();
|
|
setRetryGeneration((value) => value + 1);
|
|
}, []);
|
|
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 logReadLimitByRunRef = useRef(new Map<string, number>());
|
|
const [truncatedRunIds, setTruncatedRunIds] = useState<ReadonlySet<string>>(new Set());
|
|
const missingTerminalLogRunIdsRef = useRef(new Set<string>());
|
|
// PAP-462 B3: buffered runs that dropped out of the `runs` list, mapped to the
|
|
// wall-clock deadline (ms) after which their buffer may be pruned. A run still
|
|
// inside its grace window is retained across the empty poll; `pruneTick` fires
|
|
// the effect again once the nearest deadline elapses so a run that stays gone
|
|
// is eventually cleaned up even if the `runs` list never changes again.
|
|
const absenceDeadlineByRunRef = useRef(new Map<string, number>());
|
|
// Backoff state for the live event socket. Held outside the socket effect
|
|
// because that effect restarts on run-metadata changes; per-effect state
|
|
// would reset a progressed delay back to its base mid-outage.
|
|
const reconnectStateRef = useRef<{ companyId: string; attempt: number } | null>(null);
|
|
const prevKnownRunIdsRef = useRef(new Set<string>());
|
|
const [pruneTick, setPruneTick] = useState(0);
|
|
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) => run.status === "running").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 prevChunks = prev.get(runId) ?? [];
|
|
const { chunks: merged, changed } = mergeRunLogChunks(
|
|
runId,
|
|
prevChunks,
|
|
chunks,
|
|
{
|
|
seenChunkKeys: seenChunkKeysRef.current,
|
|
trimmedSeqFloorByRun: trimmedSeqFloorByRunRef.current,
|
|
},
|
|
retentionBudget,
|
|
);
|
|
if (!changed) return prev;
|
|
const next = new Map(prev);
|
|
next.set(runId, merged);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
const knownRunIds = new Set(normalizedRuns.map((run) => run.id));
|
|
const now = Date.now();
|
|
const deadlines = absenceDeadlineByRunRef.current;
|
|
|
|
// PAP-462 B3: a run that just disappeared from the list starts its grace
|
|
// clock; one that reappeared clears any pending deadline. Comparing against
|
|
// the previous known set means a transient empty poll only *arms* the timer
|
|
// rather than pruning the buffer outright.
|
|
for (const runId of prevKnownRunIdsRef.current) {
|
|
if (!knownRunIds.has(runId) && !deadlines.has(runId)) {
|
|
deadlines.set(runId, now + RUN_ABSENCE_PRUNE_GRACE_MS);
|
|
}
|
|
}
|
|
for (const runId of knownRunIds) {
|
|
deadlines.delete(runId);
|
|
}
|
|
prevKnownRunIdsRef.current = knownRunIds;
|
|
|
|
// Retain known runs plus any absent run still inside its grace window; only
|
|
// runs absent past their deadline are actually pruned.
|
|
const retainedRunIds = new Set(knownRunIds);
|
|
let soonestExpiryMs = Number.POSITIVE_INFINITY;
|
|
for (const [runId, deadline] of deadlines) {
|
|
if (deadline > now) {
|
|
retainedRunIds.add(runId);
|
|
soonestExpiryMs = Math.min(soonestExpiryMs, deadline);
|
|
} else {
|
|
deadlines.delete(runId);
|
|
}
|
|
}
|
|
|
|
setChunksByRun((prev) => {
|
|
const next = new Map<string, RunLogChunk[]>();
|
|
for (const [runId, chunks] of prev) {
|
|
if (retainedRunIds.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 (retainedRunIds.has(runId)) {
|
|
next.add(runId);
|
|
}
|
|
}
|
|
return next.size === prev.size ? prev : next;
|
|
});
|
|
|
|
setErrorsByRun((previous) => {
|
|
const next = new Map([...previous].filter(([id]) => retainedRunIds.has(id)));
|
|
return next.size === previous.size ? previous : next;
|
|
});
|
|
setTruncatedRunIds((previous) => {
|
|
const next = new Set([...previous].filter((id) => retainedRunIds.has(id)));
|
|
return next.size === previous.size ? previous : next;
|
|
});
|
|
|
|
for (const key of pendingLogRowsByRunRef.current.keys()) {
|
|
const runId = key.replace(/:records$/, "");
|
|
if (!retainedRunIds.has(runId)) {
|
|
pendingLogRowsByRunRef.current.delete(key);
|
|
}
|
|
}
|
|
for (const runId of logOffsetByRunRef.current.keys()) {
|
|
if (!retainedRunIds.has(runId)) {
|
|
logOffsetByRunRef.current.delete(runId);
|
|
logReadLimitByRunRef.current.delete(runId);
|
|
}
|
|
}
|
|
for (const runId of trimmedSeqFloorByRunRef.current.keys()) {
|
|
if (!retainedRunIds.has(runId)) {
|
|
trimmedSeqFloorByRunRef.current.delete(runId);
|
|
}
|
|
}
|
|
for (const runId of missingTerminalLogRunIdsRef.current.keys()) {
|
|
if (!retainedRunIds.has(runId)) {
|
|
missingTerminalLogRunIdsRef.current.delete(runId);
|
|
}
|
|
}
|
|
for (const runId of transcriptCacheRef.current.keys()) {
|
|
if (!retainedRunIds.has(runId)) {
|
|
transcriptCacheRef.current.delete(runId);
|
|
}
|
|
}
|
|
|
|
// Re-run once the nearest grace window elapses so a run that stays gone is
|
|
// pruned even if `normalizedRuns` never changes again.
|
|
if (soonestExpiryMs !== Number.POSITIVE_INFINITY) {
|
|
const timer = window.setTimeout(
|
|
() => setPruneTick((tick) => tick + 1),
|
|
Math.max(0, soonestExpiryMs - now) + 50,
|
|
);
|
|
return () => window.clearTimeout(timer);
|
|
}
|
|
}, [normalizedRuns, pruneTick]);
|
|
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
const readableRuns = normalizedRuns.filter(canReadPersistedLog);
|
|
if (readableRuns.length === 0) return;
|
|
|
|
let cancelled = false;
|
|
const controller = new AbortController();
|
|
const inFlightRunIds = new Set<string>();
|
|
|
|
const readRunLog = async (run: RunTranscriptSource) => {
|
|
if (missingTerminalLogRunIdsRef.current.has(run.id) || inFlightRunIds.has(run.id)) {
|
|
return;
|
|
}
|
|
inFlightRunIds.add(run.id);
|
|
let alignStartToRecord = !logOffsetByRunRef.current.has(run.id);
|
|
let offset = logOffsetByRunRef.current.get(run.id) ?? resolveInitialLogOffset(run, logReadLimitBytes);
|
|
let rewinding = false;
|
|
const previousReadLimit = logReadLimitByRunRef.current.get(run.id);
|
|
if (previousReadLimit !== undefined && logReadLimitBytes > previousReadLimit) {
|
|
// Expanding a compact tail must reread the earlier range. Retain the
|
|
// visible chunks; merging by sequence deduplicates the overlapping tail.
|
|
const expandedOffset = resolveInitialLogOffset(run, logReadLimitBytes);
|
|
if (expandedOffset < offset) {
|
|
alignStartToRecord = true;
|
|
offset = expandedOffset;
|
|
rewinding = true;
|
|
}
|
|
}
|
|
try {
|
|
const result = await readTranscriptRequest(
|
|
(signal) => heartbeatsApi.log(run.id, offset, logReadLimitBytes, {
|
|
signal,
|
|
...(alignStartToRecord && offset > 0 ? { alignStartToRecord: true } : {}),
|
|
}),
|
|
controller.signal,
|
|
);
|
|
if (cancelled) return;
|
|
|
|
// Commit the expanded window only after success so retries still align
|
|
// its first record and failed reads leave the previous parser intact.
|
|
if (previousReadLimit === undefined || rewinding) {
|
|
setTruncatedRunIds((previous) => {
|
|
if (previous.has(run.id) === (offset > 0)) return previous;
|
|
const next = new Set(previous);
|
|
if (offset > 0) next.add(run.id);
|
|
else next.delete(run.id);
|
|
return next;
|
|
});
|
|
}
|
|
logReadLimitByRunRef.current.set(run.id, logReadLimitBytes);
|
|
if (rewinding) {
|
|
pendingLogRowsByRunRef.current.delete(run.id);
|
|
pendingLogRowsByRunRef.current.delete(`${run.id}:records`);
|
|
}
|
|
setErrorsByRun((previous) => {
|
|
if (!previous.has(run.id)) return previous;
|
|
const next = new Map(previous);
|
|
next.delete(run.id);
|
|
return next;
|
|
});
|
|
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 (cancelled) return;
|
|
if (error instanceof ApiError && error.status === 404) {
|
|
setErrorsByRun((previous) => {
|
|
if (!previous.has(run.id)) return previous;
|
|
const next = new Map(previous);
|
|
next.delete(run.id);
|
|
return next;
|
|
});
|
|
// A newly started run may not have created its log yet.
|
|
if (isTerminalStatus(run.status)) missingTerminalLogRunIdsRef.current.add(run.id);
|
|
} else {
|
|
setErrorsByRun((previous) => {
|
|
if (previous.has(run.id)) return previous;
|
|
const next = new Map(previous);
|
|
next.set(run.id, error instanceof Error ? error : new Error("Run history could not be loaded"));
|
|
return next;
|
|
});
|
|
}
|
|
} finally {
|
|
inFlightRunIds.delete(run.id);
|
|
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(readableRuns.map((run) => readRunLog(run)));
|
|
};
|
|
|
|
void readAll();
|
|
const activeRuns = readableRuns.filter((run) => run.status === "running");
|
|
// 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;
|
|
controller.abort();
|
|
if (interval !== null) window.clearInterval(interval);
|
|
};
|
|
}, [visible, enableRealtimeUpdates, logPollIntervalMs, logReadLimitBytes, normalizedRuns, runIdsKey, retryGeneration]);
|
|
|
|
useEffect(() => {
|
|
if (!visible || !enableRealtimeUpdates) return;
|
|
if (!companyId || activeRunIds.size === 0) return;
|
|
|
|
let closed = false;
|
|
let reconnectTimer: number | null = null;
|
|
let socket: WebSocket | null = null;
|
|
|
|
// The attempt counter lives in a ref keyed to the company: this effect
|
|
// restarts whenever run metadata changes, and a per-effect counter would
|
|
// reset the backoff to its base delay mid-outage on every such restart.
|
|
if (reconnectStateRef.current?.companyId !== companyId) {
|
|
reconnectStateRef.current = { companyId, attempt: 0 };
|
|
}
|
|
const reconnectState = reconnectStateRef.current;
|
|
|
|
// Exponential backoff (1.5s → 15s cap), mirroring LiveUpdatesProvider.
|
|
// A flat retry hammers a backend that is still cold-starting — every
|
|
// failed handshake immediately queues the next one, so a stack that
|
|
// takes a minute to come up sees a steady stream of doomed connections.
|
|
const scheduleReconnect = () => {
|
|
if (closed) return;
|
|
reconnectState.attempt += 1;
|
|
const delayMs = Math.min(15_000, 1_500 * 2 ** Math.min(reconnectState.attempt - 1, 4));
|
|
reconnectTimer = window.setTimeout(connect, delayMs);
|
|
};
|
|
|
|
const connect = () => {
|
|
if (closed) return;
|
|
const url = buildSameOriginWebSocketUrl(
|
|
`/api/companies/${encodeURIComponent(companyId)}/events/ws`,
|
|
);
|
|
socket = tryCreateWebSocket(url);
|
|
if (!socket) {
|
|
scheduleReconnect();
|
|
return;
|
|
}
|
|
|
|
socket.onopen = () => {
|
|
if (closed) return;
|
|
reconnectState.attempt = 0;
|
|
};
|
|
|
|
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 === SOCKET_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 === SOCKET_OPEN) {
|
|
socket.close(1000, "live_run_transcripts_unmount");
|
|
}
|
|
}
|
|
};
|
|
}, [visible, 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,
|
|
hydratedRunIds, truncatedRunIds, errorsByRun, retry,
|
|
isInitialHydrating: normalizedRuns.some((run) => canReadPersistedLog(run) && !hydratedRunIds.has(run.id)),
|
|
hasOutputForRun(runId: string) {
|
|
return (chunksByRun.get(runId)?.length ?? 0) > 0 || runById.get(runId)?.hasStoredOutput === true;
|
|
},
|
|
};
|
|
}
|