import { useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { IssueRecoveryAction } from "@paperclipai/shared"; import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared"; import { accessApi } from "../api/access"; import { issuesApi } from "../api/issues"; import { useDraftOwner, useDraftOwnerReader } from "../hooks/useDraftOwner"; import { 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"; /** Requests preservation; the existing worker owns stop, copyback and verification. */ export function WorkspaceExportRecovery({ action, className, issueId, companyId, showStatus = true }: { className?: string; action: IssueRecoveryAction | null; issueId?: string; companyId?: string; showStatus?: boolean; }) { if (action && issueId && companyId && isRetainedExport(action, issueId, companyId)) { return ; } if (!showStatus) return null; const ownerRecovery = action?.cause === "native_workspace_finalization_owner_unverified"; if (!action || (!ownerRecovery && !isNativeWorkspaceExportRepairCause(action.cause)) || action.ownerType !== "board" || !["active", "escalated"].includes(action.status) || typeof action.evidence.runId !== "string") return null; 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…" : "Workspace recovery needs operator assistance. 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; } type RecoveryInput = { actionId: string; runId: string }; function RetainedFilesRecovery({ action, issueId, companyId, className }: { action: IssueRecoveryAction; issueId: string; companyId: string; className?: string; }) { const runId = action.evidence.runId as string; const owner = useDraftOwner(); const readOwner = useDraftOwnerReader(); const client = useQueryClient(); const busy = useRef(false); const [acknowledged, setAcknowledged] = useState(false); const [checkedUnconfirmed, setCheckedUnconfirmed] = useState(false); const [notice, setNotice] = useState(null); const access = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess(), retry: false }); const canMutate = Boolean(owner && (access.data?.source === "local_implicit" || owner !== "signed-out" && access.data?.userId === owner) && canBoardResolveRecoveryAction(companyId, access.data)); const source = useQuery({ queryKey: queryKeys.issues.recoveryActions(issueId, action.id, runId), queryFn: () => issuesApi.recoveryActions(issueId, { runId, actionId: action.id }), enabled: canMutate, retry: false }); const storageKey = owner ? pendingSendKeyFor(`workspace-file-recovery:${companyId}:${issueId}:${action.id}:${runId}`, owner) : null; const matchesInput = (value: unknown): value is RecoveryInput => { const input = asObject(value); return input.actionId === action.id && input.runId === runId; }; const pending = storageKey ? readPendingSend(storageKey, matchesInput) : null; function boundAction(data: typeof source.data) { const current = data?.actions.find(item => item.id === action.id); const inspected = data?.sourceInspection; const state = asObject(current?.evidence.workspaceRecovery); return current && isRetainedExport(current, issueId, companyId) && current.evidence.runId === runId && state.runId === runId && state.schema === "paperclip.workspace-recovery.v1" && inspected?.actionId === action.id && inspected.runId === runId && inspected.status === "verified" ? current : null; } const current = boundAction(source.data); const recovery = asObject(current?.evidence.workspaceRecovery); const system = asObject(current?.evidence.systemRecovery); const serverAcknowledged = Boolean(pending && Array.isArray(recovery.requestIds) && recovery.requestIds.includes(pending.idempotencyKey)); const restoring = ["queued", "recovering"].includes(String(recovery.status)); const saved = recovery.status === "recovered" && system.state === "complete" && Array.isArray(system.preservationOperationIds) && system.preservationOperationIds.length > 0 && system.preservationOperationIds.every(id => typeof id === "string" && id.length > 0); const retryable = recovery.status === "blocked" && recovery.retryable === true; const invalidate = () => { void client.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) }); void client.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId) }); void client.invalidateQueries({ queryKey: queryKeys.runDetail(runId) }); }; const request = useMutation({ retry: false, mutationFn: async (restartFailedAttempt: boolean) => { if (busy.current) return; busy.current = true; setNotice(null); setCheckedUnconfirmed(false); setAcknowledged(false); try { const observed = await source.refetch(); const fresh = boundAction(observed.data); const state = asObject(fresh?.evidence.workspaceRecovery); if (observed.error || !fresh || state.status !== "blocked" || state.retryable !== true) throw new Error("File recovery changed. Inspect its current status before requesting recovery."); if (!canMutate || !owner || readOwner() !== owner || !storageKey) throw new Error("Your account changed. Refresh before requesting file recovery."); const previous = readPendingSend(storageKey, matchesInput); if (restartFailedAttempt && (!previous || !Array.isArray(state.requestIds) || !state.requestIds.includes(previous.idempotencyKey) || asObject(fresh.evidence.systemRecovery).state !== "exhausted")) throw new Error("The previous failed recovery is not confirmed. Check its status before trying again."); // Unknown acceptance reuses its identity. A new bounded request requires // an explicit operator click and a freshly confirmed exhausted attempt. const record = (!restartFailedAttempt && previous) || { idempotencyKey: createUuid(), payload: { actionId: action.id, runId }, createdAt: Date.now(), }; if (!writePendingSend(storageKey, record)) throw new Error("The recovery request could not be saved in this browser. No request was sent."); try { const result = await issuesApi.recoverWorkspace(issueId, { ...record.payload, idempotencyKey: record.idempotencyKey }); if (result.actionId !== action.id || !["queued", "recovering", "blocked", "recovered"].includes(result.status)) throw new Error("The recovery acknowledgement could not be confirmed."); setAcknowledged(true); setCheckedUnconfirmed(false); invalidate(); await source.refetch(); } catch (error) { setNotice(classifySendFailure(error) === "rejected" ? `Recovery request was not confirmed: ${error instanceof Error ? error.message : "Inspect its status."}` : "The recovery request could not be confirmed. Check its status before trying again; the saved request will be reused."); } } catch (error) { setNotice(error instanceof Error ? error.message : "Unable to inspect file recovery."); } finally { busy.current = false; } } }); const check = useMutation({ retry: false, mutationFn: async () => { const observed = await source.refetch(); if (observed.error) throw observed.error; const fresh = boundAction(observed.data); if (!fresh) throw new Error("File recovery changed. Refresh the task to inspect the current recovery."); const state = asObject(fresh.evidence.workspaceRecovery); const ids = state.requestIds; const unconfirmed = Boolean(pending && Array.isArray(ids) && !ids.includes(pending.idempotencyKey)); setCheckedUnconfirmed(unconfirmed); setNotice(unconfirmed ? "No recovery request is confirmed yet. Trying again will reuse the saved request." : null); invalidate(); } }); const failedReceipt = serverAcknowledged && recovery.status === "blocked"; const confirmedFailedAttempt = failedReceipt && retryable && system.state === "exhausted"; const waiting = !failedReceipt && !saved && (restoring || acknowledged || serverAcknowledged); const unconfirmed = Boolean(pending && !acknowledged && !serverAcknowledged); return

{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 && retryable && !waiting && (!pending || unconfirmed && checkedUnconfirmed || confirmedFailedAttempt) ? : null} {waiting || unconfirmed || failedReceipt || source.isError || source.data && !current ? : null} {source.isPending ?

Inspecting the recorded file source…

: null} {source.isError || check.isError || notice ?

{notice ?? (check.error instanceof Error ? check.error.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 source remains protected; service assistance is needed.

: null} {source.data && !current ?

The recorded source is unavailable or has changed. Inspect recovery before continuing.

: null} : access.isError ?

Task access could not be checked. Refresh before requesting file recovery.

: access.isPending || !owner ?

Checking file recovery access…

:

A board member with task access can request file recovery.

}
; }