[codex] Harden dashboard run activity charts (#4126)
## Thinking Path > - Paperclip gives operators a live view of agent work across dashboards, transcripts, and run activity charts > - Those views consume live run updates and aggregate run activity from backend dashboard data > - Missing or partial run data could make charts brittle, and live transcript updates were heavier than needed > - Operators need dashboard data to stay stable even when recent run payloads are incomplete > - This pull request hardens dashboard run aggregation, guards chart rendering, and lightens live run update handling > - The benefit is a more reliable dashboard during active agent execution ## What Changed - Added dashboard run activity types and backend aggregation coverage. - Guarded activity chart rendering when run data is missing or partial. - Reduced live transcript update churn in active agent and run chat surfaces. - Fixed issue chat avatar alignment in the thread renderer. - Added focused dashboard, activity chart, and live transcript tests. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `pnpm exec vitest run server/src/__tests__/dashboard-service.test.ts ui/src/components/ActivityCharts.test.tsx ui/src/components/transcript/useLiveRunTranscripts.test.tsx` - Result: 8 tests passed, 1 skipped. The embedded Postgres dashboard service test skipped on this host with the existing PGlite/Postgres init warning; UI chart and transcript tests passed. ## Risks - Medium-low risk: aggregation semantics changed, but the UI remains guarded around incomplete data. - The dashboard service test is host-skipped here, so CI should confirm the embedded database path. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent based on GPT-5, tool-enabled local shell and GitHub workflow, exact runtime context window 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots, or documented why targeted component tests are sufficient here - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -9,6 +9,7 @@ import { queryKeys } from "../../lib/queryKeys";
|
||||
|
||||
const LOG_POLL_INTERVAL_MS = 2000;
|
||||
const LOG_READ_LIMIT_BYTES = 256_000;
|
||||
const EMPTY_RUN_LOG_CHUNKS: RunLogChunk[] = [];
|
||||
|
||||
export interface RunTranscriptSource {
|
||||
id: string;
|
||||
@@ -21,6 +22,9 @@ interface UseLiveRunTranscriptsOptions {
|
||||
runs: RunTranscriptSource[];
|
||||
companyId?: string | null;
|
||||
maxChunksPerRun?: number;
|
||||
logPollIntervalMs?: number;
|
||||
logReadLimitBytes?: number;
|
||||
enableRealtimeUpdates?: boolean;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
@@ -71,6 +75,9 @@ export function useLiveRunTranscripts({
|
||||
runs,
|
||||
companyId,
|
||||
maxChunksPerRun = 200,
|
||||
logPollIntervalMs = LOG_POLL_INTERVAL_MS,
|
||||
logReadLimitBytes = LOG_READ_LIMIT_BYTES,
|
||||
enableRealtimeUpdates = true,
|
||||
}: UseLiveRunTranscriptsOptions) {
|
||||
const runsKey = useMemo(
|
||||
() =>
|
||||
@@ -87,6 +94,13 @@ export function useLiveRunTranscripts({
|
||||
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(() => {
|
||||
@@ -167,6 +181,11 @@ export function useLiveRunTranscripts({
|
||||
missingTerminalLogRunIdsRef.current.delete(runId);
|
||||
}
|
||||
}
|
||||
for (const runId of transcriptCacheRef.current.keys()) {
|
||||
if (!knownRunIds.has(runId)) {
|
||||
transcriptCacheRef.current.delete(runId);
|
||||
}
|
||||
}
|
||||
}, [normalizedRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -180,7 +199,7 @@ export function useLiveRunTranscripts({
|
||||
}
|
||||
const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
|
||||
try {
|
||||
const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES);
|
||||
const result = await heartbeatsApi.log(run.id, offset, logReadLimitBytes);
|
||||
if (cancelled) return;
|
||||
|
||||
appendChunks(run.id, parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current));
|
||||
@@ -214,19 +233,20 @@ export function useLiveRunTranscripts({
|
||||
|
||||
void readAll();
|
||||
const activeRuns = normalizedRuns.filter((run) => !isTerminalStatus(run.status));
|
||||
const interval = activeRuns.length > 0
|
||||
const interval = activeRuns.length > 0 && logPollIntervalMs > 0
|
||||
? window.setInterval(() => {
|
||||
void Promise.all(activeRuns.map((run) => readRunLog(run)));
|
||||
}, LOG_POLL_INTERVAL_MS)
|
||||
}, logPollIntervalMs)
|
||||
: null;
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (interval !== null) window.clearInterval(interval);
|
||||
};
|
||||
}, [normalizedRuns, runIdsKey]);
|
||||
}, [logPollIntervalMs, logReadLimitBytes, normalizedRuns, runIdsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableRealtimeUpdates) return;
|
||||
if (!companyId || activeRunIds.size === 0) return;
|
||||
|
||||
let closed = false;
|
||||
@@ -334,19 +354,45 @@ export function useLiveRunTranscripts({
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [activeRunIds, companyId, runById]);
|
||||
}, [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);
|
||||
next.set(
|
||||
run.id,
|
||||
buildTranscript(chunksByRun.get(run.id) ?? [], adapter, {
|
||||
censorUsernameInLogs,
|
||||
}),
|
||||
);
|
||||
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]);
|
||||
|
||||
Reference in New Issue
Block a user