import { useMemo, useState, type ReactNode } from "react"; import type { ActivityEvent, Issue, Agent } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "@/lib/router"; import { accessApi, type CurrentBoardAccess } from "../api/access"; import { activityApi, type RunForIssue, type RunLivenessState } from "../api/activity"; import { ApiError } from "../api/client"; import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue, type WatchdogDecisionInput, } from "../api/heartbeats"; import { useToastActions } from "../context/ToastContext"; import { cn, relativeTime } from "../lib/utils"; import { queryKeys } from "../lib/queryKeys"; import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data"; import { describeRunRetryState } from "../lib/runRetryState"; type IssueRunLedgerProps = { issueId: string; companyId: string; issueStatus: Issue["status"]; childIssues: Issue[]; agentMap: ReadonlyMap; hasLiveRuns: boolean; activityEvents?: ActivityEvent[]; renderActivityEvent?: (event: ActivityEvent) => ReactNode; }; type IssueRunLedgerContentProps = { runs: RunForIssue[]; liveRuns?: LiveRunForIssue[]; activeRun?: ActiveRunForIssue | null; issueStatus: Issue["status"]; childIssues: Issue[]; agentMap: ReadonlyMap>; activityEvents?: ActivityEvent[]; renderActivityEvent?: (event: ActivityEvent) => ReactNode; pendingWatchdogDecision?: WatchdogDecisionInput["decision"] | null; canRecordWatchdogDecisions?: boolean; watchdogDecisionError?: string | null; onWatchdogDecision?: (input: WatchdogDecisionInput) => void; }; type LedgerRun = RunForIssue & { isLive?: boolean; agentName?: string; outputSilence?: ActiveRunForIssue["outputSilence"]; }; type LedgerFeedItem = | { kind: "run"; id: string; timestamp: string; run: LedgerRun; } | { kind: "activity"; id: string; timestamp: string; event: ActivityEvent; }; type LivenessCopy = { label: string; tone: string; description: string; }; const LIVENESS_COPY: Record = { completed: { label: "Completed", tone: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", description: "Issue reached a terminal state.", }, advanced: { label: "Advanced", tone: "border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300", description: "Run produced concrete evidence of progress.", }, plan_only: { label: "Plan only", tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", description: "Run described future work without concrete action evidence.", }, empty_response: { label: "Empty response", tone: "border-orange-500/30 bg-orange-500/10 text-orange-700 dark:text-orange-300", description: "Run finished without useful output.", }, blocked: { label: "Blocked", tone: "border-yellow-500/30 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300", description: "Run or issue declared a blocker.", }, failed: { label: "Failed", tone: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300", description: "Run ended unsuccessfully.", }, needs_followup: { label: "Needs follow-up", tone: "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300", description: "Run produced useful output but did not prove concrete progress.", }, }; const PENDING_LIVENESS_COPY: LivenessCopy = { label: "Checks after finish", tone: "border-border bg-background text-muted-foreground", description: "Liveness is evaluated after the run finishes.", }; const RETRY_PENDING_LIVENESS_COPY: LivenessCopy = { label: "Retry pending", tone: "border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300", description: "Paperclip queued an automatic retry that has not started yet.", }; const MISSING_LIVENESS_COPY: LivenessCopy = { label: "No liveness data", tone: "border-border bg-background text-muted-foreground", description: "This run has no persisted liveness classification.", }; const TERMINAL_CHILD_STATUSES = new Set(["done", "cancelled"]); const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]); type RunOutputSilenceLevel = NonNullable["level"]; type RunOutputSilenceCopy = { label: string; tone: string; }; const RUN_OUTPUT_SILENCE_COPY: Partial> = { suspicious: { label: "Silence watch", tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", }, critical: { label: "Stale run", tone: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300", }, snoozed: { label: "Silence snoozed", tone: "border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300", }, }; function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; } function readString(value: unknown) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } interface ModelProfileSummary { requested: string; applied: string | null; configSource: string | null; fallbackReason: string | null; } function modelProfileForRun(run: RunForIssue): ModelProfileSummary | null { const result = asRecord(run.resultJson); const profile = asRecord(result?.modelProfile); if (!profile) return null; const requested = readString(profile.requested); if (!requested) return null; return { requested, applied: readString(profile.applied), configSource: readString(profile.configSource), fallbackReason: readString(profile.fallbackReason), }; } function modelProfileBadgeTone(summary: ModelProfileSummary) { if (summary.applied === summary.requested) { return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; } if (summary.fallbackReason) { return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; } return "border-border bg-background text-muted-foreground"; } function modelProfileTitle(summary: ModelProfileSummary) { const lines = [`Requested: ${summary.requested}`]; if (summary.applied) lines.push(`Applied: ${summary.applied}`); if (summary.configSource) lines.push(`Source: ${summary.configSource}`); if (summary.fallbackReason) lines.push(`Fallback: ${summary.fallbackReason}`); return lines.join("\n"); } function readNumber(value: unknown) { return typeof value === "number" && Number.isFinite(value) ? value : null; } function formatDuration(start: string | Date | null | undefined, end: string | Date | null | undefined) { if (!start) return null; const startMs = new Date(start).getTime(); const endMs = end ? new Date(end).getTime() : Date.now(); if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null; const totalSeconds = Math.max(0, Math.round((endMs - startMs) / 1000)); if (totalSeconds < 60) return `${totalSeconds}s`; const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (minutes < 60) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; } function toIsoString(value: string | Date | null | undefined) { if (!value) return null; return value instanceof Date ? value.toISOString() : value; } function liveRunToLedgerRun(run: LiveRunForIssue | ActiveRunForIssue): LedgerRun { return { runId: run.id, status: run.status, agentId: run.agentId, agentName: run.agentName, adapterType: run.adapterType, startedAt: toIsoString(run.startedAt), finishedAt: toIsoString(run.finishedAt), createdAt: toIsoString(run.createdAt) ?? new Date().toISOString(), invocationSource: run.invocationSource, usageJson: null, resultJson: null, isLive: run.status === "queued" || run.status === "running", outputSilence: run.outputSilence, }; } function mergeRuns( runs: RunForIssue[], liveRuns: LiveRunForIssue[] | undefined, activeRun: ActiveRunForIssue | null | undefined, ) { const byId = new Map(); for (const run of runs) byId.set(run.runId, run); for (const run of liveRuns ?? []) { const existing = byId.get(run.id); byId.set( run.id, existing ? { ...existing, isLive: true, agentName: run.agentName, outputSilence: run.outputSilence } : liveRunToLedgerRun(run), ); } if (activeRun) { const existing = byId.get(activeRun.id); if (existing) { byId.set(activeRun.id, { ...existing, isLive: isActiveRun(existing) || isActiveRun(activeRun), agentName: activeRun.agentName, outputSilence: activeRun.outputSilence, }); } else { byId.set(activeRun.id, liveRunToLedgerRun(activeRun)); } } return [...byId.values()].sort((a, b) => { const aTime = new Date(a.startedAt ?? a.createdAt).getTime(); const bTime = new Date(b.startedAt ?? b.createdAt).getTime(); if (aTime !== bTime) return bTime - aTime; return b.runId.localeCompare(a.runId); }); } function statusLabel(status: string) { return status.replace(/_/g, " "); } function isActiveRun(run: Pick) { return run.isLive || ACTIVE_RUN_STATUSES.has(run.status); } function runSummary(run: LedgerRun, agentMap: ReadonlyMap>) { const agentName = compactAgentName(run, agentMap); if (run.status === "running") return `Running now by ${agentName}`; if (run.status === "queued") return `Queued for ${agentName}`; if (run.status === "scheduled_retry") return `Automatic retry scheduled for ${agentName}`; return `${statusLabel(run.status)} by ${agentName}`; } function livenessCopyForRun(run: LedgerRun) { if (run.status === "scheduled_retry") return RETRY_PENDING_LIVENESS_COPY; if (run.livenessState) return LIVENESS_COPY[run.livenessState]; return isActiveRun(run) ? PENDING_LIVENESS_COPY : MISSING_LIVENESS_COPY; } function stopReasonLabel(run: RunForIssue) { const result = asRecord(run.resultJson); const stopReason = readString(result?.stopReason); const timeoutFired = result?.timeoutFired === true; const effectiveTimeoutSec = readNumber(result?.effectiveTimeoutSec); const timeoutText = effectiveTimeoutSec && effectiveTimeoutSec > 0 ? `${effectiveTimeoutSec}s timeout` : null; if (timeoutFired || stopReason === "timeout") { return timeoutText ? `timeout (${timeoutText})` : "timeout"; } if (stopReason === "max_turns_exhausted" || stopReason === "turn_limit_exhausted") return "max turns exhausted"; if (stopReason === "budget_paused") return "budget paused"; if (stopReason === "cancelled") return "cancelled"; if (stopReason === "paused") return "paused by board"; if (stopReason === "process_lost") return "process lost"; if (stopReason === "adapter_failed") return "adapter failed"; if (stopReason === "completed") return timeoutText ? `completed (${timeoutText})` : "completed"; return timeoutText; } function stopStatusLabel(run: LedgerRun, stopReason: string | null) { if (stopReason) return stopReason; if (run.status === "scheduled_retry") return "Retry pending"; if (run.status === "queued") return "Waiting to start"; if (run.status === "running") return "Still running"; if (!run.livenessState) return "Unavailable"; return "No stop reason"; } function lastUsefulActionLabel(run: LedgerRun) { if (run.status === "scheduled_retry") return "Waiting for next attempt"; if (run.lastUsefulActionAt) return relativeTime(run.lastUsefulActionAt); if (isActiveRun(run)) return "No action recorded yet"; if (run.livenessState === "plan_only" || run.livenessState === "needs_followup") { return "No concrete action"; } if (run.livenessState === "empty_response") return "No useful output"; if (!run.livenessState) return "Unavailable"; return "None recorded"; } function continuationLabel(run: LedgerRun) { if (!run.continuationAttempt || run.continuationAttempt <= 0) return null; return `Continuation attempt ${run.continuationAttempt}`; } function hasExhaustedContinuation(run: RunForIssue) { return /continuation attempts exhausted/i.test(run.livenessReason ?? ""); } function childIssueSummary(childIssues: Issue[]) { const active = childIssues.filter((issue) => !TERMINAL_CHILD_STATUSES.has(issue.status)); const done = childIssues.filter((issue) => issue.status === "done").length; const cancelled = childIssues.filter((issue) => issue.status === "cancelled").length; return { active, done, cancelled, total: childIssues.length }; } function compactAgentName(run: LedgerRun, agentMap: ReadonlyMap>) { return run.agentName ?? agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8); } function formatSilenceAge(ms: number | null | undefined) { if (!ms || ms <= 0) return null; const totalMinutes = Math.floor(ms / 60_000); if (totalMinutes < 1) return "under 1 minute"; if (totalMinutes < 60) return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`; const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; if (minutes === 0) return `${hours} hour${hours === 1 ? "" : "s"}`; return `${hours}h ${minutes}m`; } function canBoardRecordWatchdogDecision( companyId: string, boardAccess: CurrentBoardAccess | undefined, ) { if (!boardAccess) return false; if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true; const membership = boardAccess.memberships?.find( (item) => item.companyId === companyId && item.status === "active", ); if (!membership) return boardAccess.companyIds.includes(companyId) && !boardAccess.memberships; return membership.membershipRole !== "viewer" && membership.membershipRole !== null; } function watchdogDecisionErrorMessage(error: unknown) { if (error instanceof ApiError && error.status === 403) { return "Only the board or the assigned recovery owner can record watchdog decisions"; } return error instanceof Error && error.message.trim().length > 0 ? error.message : "Paperclip could not record the watchdog decision."; } export function IssueRunLedger({ issueId, companyId, issueStatus, childIssues, agentMap, hasLiveRuns, activityEvents, renderActivityEvent, }: IssueRunLedgerProps) { const queryClient = useQueryClient(); const { pushToast } = useToastActions(); const [watchdogDecisionError, setWatchdogDecisionError] = useState(null); const { data: boardAccess } = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess(), retry: false, }); const { data: runs } = useQuery({ queryKey: queryKeys.issues.runs(issueId), queryFn: () => activityApi.runsForIssue(issueId), refetchInterval: hasLiveRuns || issueStatus === "in_progress" ? 5000 : false, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); const { data: liveRuns } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueId), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId), enabled: hasLiveRuns, refetchInterval: 3000, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); const { data: activeRun = null } = useQuery({ queryKey: queryKeys.issues.activeRun(issueId), queryFn: () => heartbeatsApi.activeRunForIssue(issueId), enabled: hasLiveRuns || issueStatus === "in_progress", refetchInterval: hasLiveRuns ? false : 3000, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); const watchdogDecision = useMutation({ mutationFn: (input: WatchdogDecisionInput) => heartbeatsApi.recordWatchdogDecision(input), onMutate: () => { setWatchdogDecisionError(null); }, onSuccess: () => { setWatchdogDecisionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) }); }, onError: (error) => { const message = watchdogDecisionErrorMessage(error); const dedupeSuffix = error instanceof ApiError ? String(error.status) : "error"; setWatchdogDecisionError(message); pushToast({ title: "Watchdog decision not recorded", body: message, tone: "error", dedupeKey: `watchdog-decision:${issueId}:${dedupeSuffix}`, }); }, }); return ( watchdogDecision.mutate(input)} /> ); } export function IssueRunLedgerContent({ runs, liveRuns, activeRun, issueStatus, childIssues, agentMap, activityEvents, renderActivityEvent, pendingWatchdogDecision, canRecordWatchdogDecisions = true, watchdogDecisionError, onWatchdogDecision, }: IssueRunLedgerContentProps) { const ledgerRuns = useMemo(() => mergeRuns(runs, liveRuns, activeRun), [activeRun, liveRuns, runs]); const latestRun = ledgerRuns[0] ?? null; const latestSilentRun = useMemo( () => ledgerRuns.find((run) => isActiveRun(run) && (run.outputSilence?.level === "critical" || run.outputSilence?.level === "suspicious"), ) ?? null, [ledgerRuns], ); const children = childIssueSummary(childIssues); const canRenderActivityEvents = Boolean(renderActivityEvent); const feedItems = useMemo(() => { const items: LedgerFeedItem[] = []; for (const run of ledgerRuns) { items.push({ kind: "run", id: run.runId, timestamp: run.startedAt ?? run.createdAt, run, }); } if (canRenderActivityEvents) { for (const event of activityEvents ?? []) { items.push({ kind: "activity", id: event.id, timestamp: event.createdAt instanceof Date ? event.createdAt.toISOString() : String(event.createdAt), event, }); } } return items.sort((a, b) => { const aTime = new Date(a.timestamp).getTime(); const bTime = new Date(b.timestamp).getTime(); if (aTime !== bTime) return bTime - aTime; if (a.kind !== b.kind) return a.kind === "run" ? -1 : 1; return b.id.localeCompare(a.id); }); }, [activityEvents, canRenderActivityEvents, ledgerRuns]); return (

Run ledger

{latestRun ? runSummary(latestRun, agentMap) : issueStatus === "in_progress" ? "Waiting for the first run record." : "No runs linked yet."}

{latestRun ? ( Latest run ) : null}
{children.total > 0 ? (
Child work {children.active.length > 0 ? `${children.active.length} active, ${children.done} done, ${children.cancelled} cancelled` : `all ${children.total} terminal (${children.done} done, ${children.cancelled} cancelled)`}
{children.active.length > 0 ? (
{children.active.slice(0, 4).map((child) => ( {child.identifier ?? child.id.slice(0, 8)} {child.title} {statusLabel(child.status)} ))} {children.active.length > 4 ? ( +{children.active.length - 4} more ) : null}
) : null}
) : null} {latestSilentRun?.outputSilence ? (

{latestSilentRun.outputSilence.level === "critical" ? "Stale-run watchdog alert" : "Output silence watchdog warning"}

Latest active run has been silent for{" "} {formatSilenceAge(latestSilentRun.outputSilence.silenceAgeMs) ?? "an extended period"}. {latestSilentRun.outputSilence.evaluationIssueIdentifier ? ( <> {" "} Review{" "} {latestSilentRun.outputSilence.evaluationIssueIdentifier} {" "}for recovery context. ) : null}

{onWatchdogDecision && canRecordWatchdogDecisions ? (
) : null} {watchdogDecisionError ? (

{watchdogDecisionError}

) : null}
) : null} {feedItems.length === 0 ? (
{renderActivityEvent ? "Runs and activity will appear here once this issue has history." : "Historical runs without liveness metadata will appear here once linked to this issue."}
) : (
{feedItems.slice(0, 20).map((item) => { if (item.kind === "activity") { return
{renderActivityEvent?.(item.event)}
; } const run = item.run; const liveness = livenessCopyForRun(run); const stopReason = stopReasonLabel(run); const duration = formatDuration(run.startedAt, run.finishedAt); const exhausted = hasExhaustedContinuation(run); const continuation = continuationLabel(run); const retryState = describeRunRetryState(run); const agentName = compactAgentName(run, agentMap); return (
Run {run.runId.slice(0, 8)} by {agentName} {statusLabel(run.status)} {run.isLive ? ( live ) : null} {liveness.label} {exhausted ? ( Exhausted ) : null} {continuation ? ( {continuation} ) : null} {retryState ? ( {retryState.badgeLabel} ) : null} {run.outputSilence && RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level] ? ( {RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.label} ) : null} {(() => { const profile = modelProfileForRun(run); if (!profile) return null; const label = profile.applied === profile.requested ? `Profile: ${profile.requested}` : profile.applied ? `Profile: ${profile.requested} → ${profile.applied}` : `Profile: ${profile.requested} (unavailable)`; return ( {label} ); })()} {relativeTime(item.timestamp)}
Elapsed{" "} {duration ?? "unknown"}
Last useful action{" "} {lastUsefulActionLabel(run)}
Stop{" "} {stopStatusLabel(run, stopReason)}
{retryState ? (
{retryState.detail ?

{retryState.detail}

: null} {retryState.secondary ?

{retryState.secondary}

: null} {retryState.retryOfRunId ? (

Retry of{" "} {retryState.retryOfRunId.slice(0, 8)}

) : null}
) : null} {(() => { const profile = modelProfileForRun(run); if (!profile?.fallbackReason || profile.applied === profile.requested) return null; return (

{profile.requested === "cheap" ? "Cheap profile fell back to primary" : `${profile.requested} profile unavailable`} {": "} {profile.fallbackReason}

); })()} {run.livenessReason ? (

{run.livenessReason}

) : null} {run.nextAction ? (
Next action: {run.nextAction}
) : null}
); })} {feedItems.length > 20 ? (
{feedItems.length - 20} older items not shown
) : null}
)}
); }