import { useMemo, useState } from "react"; import type { Agent, GitWorktreeBranchAncestryVerdict, IssueRecoveryAction, IssueRecoveryActionKind, IssueRecoveryActionOutcome, IssueRecoveryActionStatus, IssueScheduledRetry, } from "@paperclipai/shared"; import { Eye, GitBranch, GitBranchPlus, Loader2, Lock, OctagonAlert, RefreshCw, Sparkles, TriangleAlert, Wrench, } from "lucide-react"; import { Link } from "@/lib/router"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; import { agentUrl } from "@/lib/utils"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { deriveRecoveryDisplayState, type RecoveryDisplayState, } from "@/lib/recovery-display"; import { formatRecoveryAttemptLabel, formatRecoveryRetryOffset, readRecoveryRetryLineage, type RecoveryRetryLineage, } from "@/lib/recovery-lineage"; export type RecoveryCardCardState = RecoveryDisplayState; export const deriveRecoveryCardState = deriveRecoveryDisplayState; export type RecoveryResolveOutcome = | "todo" | "done" | "in_review" | "false_positive_done" | "false_positive_in_review"; /** * Payload for the "Re-issue on isolated workspace" action (workspace_validation only). * The caller composes an isolated-workspace re-issue whose git worktree bases off `baseRef` * — the live (checked-out) branch that diverged, or its HEAD sha when the branch is detached. */ export interface RecoveryReissueRequest { baseRef: string; liveBranch: string | null; liveHeadSha: string | null; expectedBranch: string | null; } export interface IssueRecoveryActionCardProps { action: IssueRecoveryAction; agentMap?: ReadonlyMap; /** * The source issue's scheduled retry. It is the only signal that can confirm the run the * wake policy parked is genuinely in flight, which is what separates a retry the scheduler * is running from one whose due time quietly passed. */ scheduledRetry?: IssueScheduledRetry | null; /** Preferred state hint (e.g. observe_only when watchdog tone is requested). Falls back to derived state. */ forcedState?: RecoveryCardCardState; /** Optional click handler for resolve menu actions. If omitted, the buttons are not rendered. */ onResolve?: (outcome: RecoveryResolveOutcome) => void; /** * Optional handler for the workspace_validation "Re-issue on isolated workspace" action. * Rendered only for a git-worktree branch-incoherence divergence with a resolvable live ref. * If omitted, the re-issue button is not shown. */ onReissueIsolated?: (request: RecoveryReissueRequest) => void; /** Whether an isolated re-issue is currently in flight (disables the action + shows a spinner). */ reissuePending?: boolean; /** * Handler for action 1 — "Reconcile forward & continue" (workspace_validation only). Rendered * only for an ancestry-proven (`ancestor`) git-worktree divergence; the caller invokes the S4 * reconcile op in `forward` mode, which re-verifies ancestry server-side (the client hint is * never trusted). If omitted, the button is not shown. */ onReconcileForward?: () => void; /** * Handler for action 2 — the audited break-glass override (workspace_validation only). Receives * the operator's required, non-empty reason and invokes the S4 reconcile op in `override` mode. * Rendered only when `canBreakGlass` is true AND this handler is provided; the server independently * rejects agent actors and re-checks runtime-manage permission, so UI hiding is defense-in-depth. */ onBreakGlassOverride?: (reason: string) => void; /** * Whether the viewer may run the permission-gated break-glass override. When false, action 2 is * not rendered at all — a non-permitted user never sees the "reconcile anyway" affordance. */ canBreakGlass?: boolean; /** * Handler for the lossless repair — "Repair workspace — quarantine changes & restore branch" * (workspace_validation only). Rendered only for a *dirty* divergence; the caller invokes the S4 * reconcile op in `quarantine_restore` mode, which quarantines the dirty worktree onto a rescue * branch and restores the recorded branch. If omitted, the repair action is not shown. */ onQuarantineRestore?: () => void; /** Whether a quarantine-restore repair is currently in flight (shares the reconcile spinner). */ quarantineRestorePending?: boolean; /** Whether a reconcile (forward, override, or quarantine-restore) is currently in flight. */ reconcilePending?: boolean; /** Whether the viewer can run destructive board-only actions (e.g. false-positive dismissal). */ canFalsePositive?: boolean; /** * Rendering density. `full` (default) shows the complete metadata table; `compact` drops the * metadata rows for embedding beside a run on the agent run page, keeping the header, divergence * diagnosis, and action footer. */ variant?: "full" | "compact"; className?: string; } const KIND_LABEL: Record = { missing_disposition: "Missing Disposition", deliberate_wait_without_target: "Wait Without A Target", stranded_assigned_issue: "Stranded Task", workspace_validation: "Workspace Validation", configuration_validation: "Configuration Validation", active_run_watchdog: "Active Watchdog", issue_graph_liveness: "Task Needs Next Step", }; const KIND_HEADLINE: Record = { missing_disposition: "This task's run finished, but no next step was chosen. Choose what happens next — try the task again, mark it done, or send it for review.", deliberate_wait_without_target: "This task's last run stopped to wait, but there is no reviewer, blocker, monitor, or approval to wait for. Paperclip is repairing the next step; the task stays with its owner.", stranded_assigned_issue: "Paperclip retried this task's last run, but there is still no queued run, reviewer, blocker, or other next owner. To get it moving, choose what happens next — try the task again, mark it done, or send it for review.", workspace_validation: "Paperclip stopped this run because the task's git workspace could not be validated.", configuration_validation: "Paperclip stopped before dispatching this run because required secret/env bindings are missing.", active_run_watchdog: "The active run has been silent. Recovery is observing without interrupting it.", issue_graph_liveness: "Paperclip could not find a clear next step for this open task. Choose whether to continue work, send it for review, mark it done, or record what is blocking it.", }; /** Shared shell for the retry-timing pill so every timing state reads as the same control. */ const RETRY_PILL_CLASS = "rounded-md border border-border/50 bg-background/60 px-1.5 py-0.5 text-(length:--text-micro) text-muted-foreground"; const STATE_TONE: Record = { needed: { label: "RECOVERY NEEDED", containerClass: "border-amber-300/70 bg-amber-50/85 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100", iconWrapClass: "bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200", iconClass: "text-amber-700 dark:text-amber-300", labelClass: "text-amber-900 dark:text-amber-200", Icon: TriangleAlert, divider: "border-amber-300/60 dark:border-amber-500/30", }, in_progress: { label: "RECOVERY IN PROGRESS", containerClass: "border-sky-300/70 bg-sky-50/80 text-sky-950 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-100", iconWrapClass: "bg-sky-100 text-sky-800 dark:bg-sky-500/20 dark:text-sky-200", iconClass: "text-sky-700 dark:text-sky-300", labelClass: "text-sky-900 dark:text-sky-200", Icon: RefreshCw, divider: "border-sky-300/60 dark:border-sky-500/30", }, observe_only: { label: "OBSERVING ACTIVE RUN", containerClass: "border-border bg-muted/40 text-foreground dark:bg-muted/20", iconWrapClass: "bg-muted text-foreground/70", iconClass: "text-muted-foreground", labelClass: "text-muted-foreground", Icon: Eye, divider: "border-border/70", }, escalated: { label: "RECOVERY ESCALATED", containerClass: "border-red-400/60 bg-red-50/85 text-red-950 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100", iconWrapClass: "bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-200", iconClass: "text-red-700 dark:text-red-300", labelClass: "text-red-900 dark:text-red-200", Icon: OctagonAlert, divider: "border-red-400/50 dark:border-red-500/30", }, resolved: { label: "RECOVERY RESOLVED", containerClass: "border-emerald-300/70 bg-emerald-50/80 text-emerald-950 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100", iconWrapClass: "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200", iconClass: "text-emerald-700 dark:text-emerald-300", labelClass: "text-emerald-900 dark:text-emerald-200", Icon: Sparkles, divider: "border-emerald-300/60 dark:border-emerald-500/30", }, }; const OUTCOME_LABEL: Record = { restored: "restored", handed_back: "handed back to original owner", owner_completed: "completed by recovery owner", delegated: "delegated to follow-up", false_positive: "false positive", blocked: "blocked", escalated: "escalated", cancelled: "cancelled", }; function readEvidenceString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); if (!trimmed) return null; return trimmed.length > 240 ? `${trimmed.slice(0, 237)}…` : trimmed; } // Human-sentence evidence sources render as prose; code-shaped sources // (error codes, statuses) stay in the mono treatment used for run ids. const PROSE_EVIDENCE_KEYS = ["summary", "detectedProgressSummary", "missingDisposition", "retryReason"] as const; const CODE_EVIDENCE_KEYS = ["latestRunErrorCode", "latestRunStatus", "latestIssueStatus"] as const; function pickEvidenceSummary(action: IssueRecoveryAction): { text: string; isCode: boolean } | null { const evidence = action.evidence ?? {}; for (const key of PROSE_EVIDENCE_KEYS) { const next = readEvidenceString(evidence[key]); if (next) return { text: next, isCode: false }; } for (const key of CODE_EVIDENCE_KEYS) { const next = readEvidenceString(evidence[key]); if (next) return { text: next, isCode: true }; } return null; } function readEvidenceRunId(action: IssueRecoveryAction, key: "sourceRunId" | "correctiveRunId" | "latestRunId") { const evidence = action.evidence ?? {}; const next = readEvidenceString(evidence[key]); return next; } function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } function asNonEmptyString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function asAncestryVerdict(value: unknown): GitWorktreeBranchAncestryVerdict | null { return value === "ancestor" || value === "diverged" || value === "unknown" ? value : null; } function formatShortSha(sha: string | null): string | null { if (!sha) return null; return sha.length > 10 ? sha.slice(0, 10) : sha; } /** * Diagnosis derived from a workspace_validation recovery action whose underlying failure is a * git-worktree branch incoherence. The evidence carries the recorded ("expected") branch, the * live ("actual"/checked-out) branch, both HEAD shas, and a server-computed ancestry verdict + * plain-language explanation of why the run was declined. */ interface WorkspaceContention { claimedByIssueId: string | null; claimedByIssueIdentifier: string | null; /** True when the claiming workspace has a queued/running run (not just a stale claim). */ hasActiveRun: boolean; } interface WorkspaceDivergence { expectedBranch: string | null; liveBranch: string | null; expectedHeadSha: string | null; liveHeadSha: string | null; ancestryVerdict: GitWorktreeBranchAncestryVerdict | null; plainLanguageReason: string | null; cleanliness: "clean" | "dirty" | "unknown" | null; /** Number of dirty (uncommitted) status entries in the live worktree, when known. */ dirtyFileCount: number | null; /** Sample of dirty paths (already truncated server-side) for the confirm step. */ dirtyPathSample: string[]; /** * Another workspace is holding the live branch. When present, the lossless quarantine repair is * refused server-side — re-issuing on an isolated workspace is the recommended path instead. */ contention: WorkspaceContention | null; /** * Preview of the rescue branch the quarantine repair will create. The server appends a UTC * timestamp at repair time, so this is the stable prefix only (rendered with a trailing marker). */ rescueBranchPreview: string; /** Ref a re-issue should base off — the live branch when known, else the live HEAD sha. */ reissueBaseRef: string | null; } /** Mirrors the server's `sanitizeBranchName` for a faithful rescue-branch preview. */ function sanitizeBranchComponent(value: string): string { return ( value .trim() .replace(/[^A-Za-z0-9._/-]+/g, "-") .replace(/-+/g, "-") .replace(/^[-/.]+|[-/.]+$/g, "") .slice(0, 120) || "issue" ); } function buildRescueBranchPreview(sourceIdentifier: string | null): string { return `paperclip/rescue/${sanitizeBranchComponent(sourceIdentifier ?? "issue")}/`; } function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); } function asNonNegativeInt(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null; } function readContention(value: unknown): WorkspaceContention | null { const record = asRecord(value); if (!record) return null; const activeRun = asRecord(record.activeRun); return { claimedByIssueId: asNonEmptyString(record.claimedByIssueId), claimedByIssueIdentifier: asNonEmptyString(record.claimedByIssueIdentifier), hasActiveRun: activeRun !== null, }; } function readWorkspaceDivergence(action: IssueRecoveryAction): WorkspaceDivergence | null { if (action.kind !== "workspace_validation") return null; const workspaceValidation = asRecord(action.evidence?.workspaceValidation); if (!workspaceValidation) return null; if (workspaceValidation.reason !== "git_worktree_branch_incoherence") return null; const provenance = asRecord(workspaceValidation.provenance) ?? {}; const expectedBranch = asNonEmptyString(workspaceValidation.expectedBranch); const liveBranch = asNonEmptyString(workspaceValidation.actualBranch); const expectedHeadSha = asNonEmptyString(provenance.expectedHeadSha); const liveHeadSha = asNonEmptyString(provenance.actualHeadSha); const cleanlinessRaw = workspaceValidation.cleanliness; const cleanliness = cleanlinessRaw === "clean" || cleanlinessRaw === "dirty" || cleanlinessRaw === "unknown" ? cleanlinessRaw : null; const sourceIdentifier = asNonEmptyString(workspaceValidation.sourceIdentifier); return { expectedBranch, liveBranch, expectedHeadSha, liveHeadSha, ancestryVerdict: asAncestryVerdict(provenance.ancestryVerdict), plainLanguageReason: asNonEmptyString(provenance.plainLanguageReason), cleanliness, dirtyFileCount: asNonNegativeInt(workspaceValidation.statusEntryCount), dirtyPathSample: asStringArray(workspaceValidation.dirtyPathSample), contention: readContention(workspaceValidation.contention), rescueBranchPreview: buildRescueBranchPreview(sourceIdentifier), reissueBaseRef: liveBranch ?? liveHeadSha, }; } const ANCESTRY_BADGE: Record< GitWorktreeBranchAncestryVerdict, { label: string; className: string } > = { ancestor: { label: "Forward-only", className: "border-emerald-400/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", }, diverged: { label: "Diverged", className: "border-red-400/50 bg-red-500/10 text-red-700 dark:text-red-300", }, unknown: { label: "Ancestry unknown", className: "border-border bg-muted/60 text-muted-foreground", }, }; function BranchFacet({ label, branch, sha, }: { label: string; branch: string | null; sha: string | null; }) { const shortSha = formatShortSha(sha); return (
{label}
{branch ? ( {branch} ) : ( detached / unknown )}
{shortSha ? `@ ${shortSha}` : "@ —"}
); } function DivergenceDiagnosis({ divergence, dividerClass, }: { divergence: WorkspaceDivergence; dividerClass: string; }) { const badge = ANCESTRY_BADGE[divergence.ancestryVerdict ?? "unknown"]; return (
Divergence diagnosis {badge.label}
{divergence.plainLanguageReason ? (

{divergence.plainLanguageReason}

) : null} {divergence.contention ? (

Worktree claimed by{" "} {contentionLabel(divergence.contention)}{" "} {divergence.contention.hasActiveRun ? "(active run)" : "(claim held)"} — the lossless repair can't run while another workspace holds the live branch.

) : null}
); } function contentionLabel(contention: WorkspaceContention): string { return ( contention.claimedByIssueIdentifier ?? (contention.claimedByIssueId ? `issue ${contention.claimedByIssueId.slice(0, 8)}` : "another task") ); } /** * Action 2 — the audited break-glass override. Gated by an explicit confirm step that *restates the * divergence* (both branches + short SHAs + ancestry verdict) and a required, non-empty reason: the * confirm button stays disabled until the operator records why. The server re-checks the actor and * permission and appends the reason to the audit log — this UI gate is the operator-facing guardrail, * not the security boundary. */ function BreakGlassOverride({ divergence, onConfirm, pending, }: { divergence: WorkspaceDivergence; onConfirm: (reason: string) => void; pending: boolean; }) { const [reason, setReason] = useState(""); const trimmedReason = reason.trim(); const canSubmit = trimmedReason.length > 0 && !pending; const verdictBadge = ANCESTRY_BADGE[divergence.ancestryVerdict ?? "unknown"]; const expectedSha = formatShortSha(divergence.expectedHeadSha); const liveSha = formatShortSha(divergence.liveHeadSha); return (
Break-glass reconciliation

This overrides Paperclip's safety check and points the recorded workspace at the live branch{" "} without an ancestry proof. Confirm the divergence below and record why before continuing.

Recorded · expected
{divergence.expectedBranch ?? "detached"} {expectedSha ? ` @ ${expectedSha}` : ""}
Live · checked out
{divergence.liveBranch ?? "detached"} {liveSha ? ` @ ${liveSha}` : ""}
Ancestry verdict
{verdictBadge.label}