import { useLayoutEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { IssueRecoveryAction, WorkspaceSourceInspection } from "@paperclipai/shared"; import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared"; import { isTransientError } from "../api/errors"; import { useQueryView } from "./QueryView"; import { accessApi } from "../api/access"; import { issuesApi } from "../api/issues"; import { useDraftOwner, useDraftOwnerReader } from "../hooks/useDraftOwner"; import { canBoardConfirmOwnerStop, canBoardResolveRecoveryAction, isRetainedWorkspaceExportCause } from "../lib/recovery-reconcile"; import { queryKeys } from "../lib/queryKeys"; import { classifySendFailure, pendingSendKeyFor, readPendingSend, writePendingSend } from "../lib/pending-send"; import { asObject, cn } from "../lib/utils"; import { createUuid } from "../lib/uuid"; import { Button } from "./ui/button"; import { Textarea } from "./ui/textarea"; /** Reports whether this surface owns the task's next recovery action. The task's * generic Retry stays hidden only while this surface offers its own control. */ function ReportRecoveryControl({ owns, onChange }: { owns: boolean; onChange?: (owns: boolean) => void }) { useLayoutEffect(() => { onChange?.(owns); return () => onChange?.(false); }, [owns, onChange]); return null; } /** Requests preservation; the existing worker owns stop, copyback and verification. */ export function WorkspaceExportRecovery({ action, className, issueId, companyId, showStatus = true, onRecoveryControlChange }: { className?: string; action: IssueRecoveryAction | null; issueId?: string; companyId?: string; showStatus?: boolean; onRecoveryControlChange?: (owns: boolean) => void; }) { // A restart killed the copyback owner of retained saved-work recovery. Recovery // and any accepted Retry wait on its receipt; the confirmation is the one action. const retainedOwnerToken = action && issueId && companyId && isRetainedExport(action, issueId, companyId) ? retainedOwnerStopToken(action) : null; if (action && issueId && companyId && retainedOwnerToken) { return <> ; } if (action && issueId && companyId && isRetainedExport(action, issueId, companyId)) { return ; } const ownerRecovery = action?.cause === "native_workspace_finalization_owner_unverified"; // The task's interruption names this wait; the confirmation is its one action. if (ownerRecovery && issueId && companyId && isOwnerStopRequired(action, issueId, companyId)) { return <> ; } if (!showStatus) return ; if (!action || (!ownerRecovery && !isNativeWorkspaceExportRepairCause(action.cause)) || action.ownerType !== "board" || !["active", "escalated"].includes(action.status) || typeof action.evidence.runId !== "string") return ; const queued = !!action.evidence.workspaceOwnerStop || action.wakePolicy?.kind === "resume_native_run"; const automatic = queued || (ownerRecovery && asObject(action.evidence.workspaceOwnerRecovery).state === "resuming"); return <>

{automatic ? "Restoring saved work…" : "Saved work is not recovered yet. The agent’s result is saved."}

; } function isRetainedExport(action: IssueRecoveryAction, issueId: string, companyId: string) { return action.companyId === companyId && action.sourceIssueId === issueId && action.kind === "active_run_watchdog" && ["active", "escalated"].includes(action.status) && ["board", "system"].includes(action.ownerType) && isRetainedWorkspaceExportCause(action.cause) && typeof action.evidence.runId === "string" && action.evidence.runId.length > 0; } function isOwnerStopRequired(action: IssueRecoveryAction, issueId: string, companyId: string) { return action.companyId === companyId && action.sourceIssueId === issueId && action.ownerType === "board" && ["active", "escalated"].includes(action.status) && typeof action.evidence.runId === "string" && typeof asObject(action.evidence.owner).token === "string" && !action.evidence.workspaceOwnerStop && asObject(action.evidence.workspaceOwnerRecovery).state !== "resuming"; } /** The exact receipt #19721 records when retained recovery waits on a stopped copyback owner. */ function retainedOwnerStopToken(action: IssueRecoveryAction): string | null { const hold = asObject(action.evidence.workspaceOwnerRecovery); const token = asObject(hold.owner).token; return hold.state === "operator_required" && !action.evidence.workspaceOwnerStop && typeof action.evidence.runId === "string" && typeof token === "string" && token.length > 0 ? token : null; } /** A previous server that can no longer report cannot confirm its own stop. * The board confirms it once; the saved result then finishes without another agent turn. */ function PreviousServerStopConfirmation({ action, ownerToken, issueId, companyId, className }: { action: IssueRecoveryAction; ownerToken: string; issueId: string; companyId: string; className?: string; }) { const runId = action.evidence.runId as string; const client = useQueryClient(); const [open, setOpen] = useState(false); const [evidence, setEvidence] = useState(""); const access = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess() }); const accessView = useQueryView(access); const canConfirm = (accessView.kind === "ready" || accessView.kind === "stale") && canBoardConfirmOwnerStop(companyId, access.data); const confirm = useMutation({ retry: false, // query-error-ok: an explicit board confirmation; the error is shown below. mutationFn: () => issuesApi.resumeWorkspaceFinalization(issueId, { actionId: action.id, runId, ownerToken, controllerAndCopybackStopped: true, stopEvidence: evidence.trim() }), onSuccess: () => { setOpen(false); void client.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) }); void client.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId) }); void client.invalidateQueries({ queryKey: queryKeys.runDetail(runId) }); } }); const ready = evidence.trim().length >= 20; return

{confirm.isSuccess ? "Restoring saved work…" : "The previous server stopped while it was saving this task’s files, and Paperclip can’t verify that it stopped. The agent’s result is saved."}

{confirm.isSuccess ? null : !canConfirm ?

{accessView.kind === "loading" || accessView.kind === "reconnecting" ? "Checking access…" : "Paperclip is waiting to confirm that the previous server stopped."}

: !open ? :
{ event.preventDefault(); if (ready && !confirm.isPending) confirm.mutate(); }}>