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 <>
{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 ? : } {confirm.error ?{confirm.error instanceof Error ? confirm.error.message : "The confirmation was not accepted. Refresh the task and try again."}
: null}{saved ? "Saved files recovered. Task recovery is still being checked." : failedReceipt ? "File recovery has not finished. The saved source remains protected." : restoring ? "Recovering saved files. Work remains paused until recovery is verified." : waiting ? "File recovery is queued. Waiting for verified saved files." : "The saved files need recovery before work can continue."}
{canMutate ? <> {current && verified && retryable && !waiting && (!pending || unconfirmed && checkedUnconfirmed || confirmedFailedAttempt) ? : null} {waiting || unconfirmed || failedReceipt || source.error || sourceReady && !current ? : null} {sourceView.kind === "loading" || sourceView.kind === "reconnecting" ?Inspecting the recorded file source…
: null} {sourceView.kind === "error" || checkError || notice ?{notice ?? (checkError instanceof Error ? checkError.message : "File recovery could not be inspected. Check its status again.")}
: null} {current && recovery.status === "blocked" && (serverAcknowledged || recovery.retryable !== true) ?File recovery has not finished. The saved files remain protected and are not applied.
: null} {unverified ?{unverifiedSourceText(source.data?.sourceInspection)} Paperclip does not apply files it can’t verify.
: null} {sourceReady && !current ?This file recovery was replaced. Check its status to show the task’s current recovery.
: null} > : accessView.kind === "error" ?Task access could not be checked. Refresh before requesting file recovery.
: accessView.kind === "loading" || accessView.kind === "reconnecting" || !owner ?Checking file recovery access…
:A board member with task access can request file recovery.
}