Files
PaperClipAI/ui/src/components/WorkspaceExportRecovery.tsx
T
DottaandClaude Opus 5.5 824b25654c fix(ui): keep a Retry for retained-file holds whose source cannot be verified (#19840)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - When a native run finishes but its files cannot be saved, the task
stays blocked behind a retained-file recovery hold.
> - The server records that hold as blocked and retryable, and the task
notice tells the user to "Retry to try again".
> - The task page hides its own Retry for these holds and relies on the
retained-file recovery panel instead.
> - That panel only binds the recovery record when a read-only source
inspection returns `verified`.
> - Inspection can return `blocked` for normal reasons: a sandbox that
predates ownership receipts (state saved by an older release), or a
workspace that moved on after its files were recovered.
> - Then neither surface offers an action, and "Check file recovery
status" always answers "File recovery changed. Refresh the task", also
after a reload.
> - This pull request binds the record by its own identity, uses
inspection only to gate the physical file-recovery request, and keeps
the task Retry visible whenever the panel has no control of its own.

## Linked Issues or Issue Description

Refs #18848 (the change that added the retained-file recovery panel and
hid the generic Retry for these holds).

**What happened?**

A blocked task with an active
`native_workspace_sync_out_retry_exhausted` hold showed "The saved files
need recovery before work can continue", "The recorded source is
unavailable or has changed", and "Saving the agent's files failed after
automatic retries. Retry to try again". It showed no Retry and no
recover button. "Check file recovery status" always answered "File
recovery changed. Refresh the task to inspect the current recovery", and
a reload did not change it. The hold was saved by an earlier release:
`systemRecovery` exhausted with `allocation_ownership_ambiguous` /
`service_required`, and `workspaceRecovery` blocked with `retryable:
true`. The source inspection endpoint returned `status: "blocked"` for
it. A second task whose files were already recovered showed the same
"need recovery" and "has changed" text, because its inspection reported
a reference mismatch after recovery.

**Expected behavior**

A retryable hold always shows a working Retry or recover control. A
notice that says "Retry" has a Retry button. Status checks never loop on
"changed, refresh". Recovered files show as recovered. When the source
cannot be verified, the page names the reason and who must inspect it.

**Steps to reproduce**

1. Use an active retained-export hold whose `workspaceRecovery` is
blocked and retryable, and whose `systemRecovery` is exhausted.
2. Make the source inspection (`GET
/api/issues/:id/recovery-actions?inspectSourceRunId=…&inspectSourceActionId=…`)
return `status: "blocked"`.
3. Open the task as a board operator.
4. Before this change, the page shows no Retry and no recover button.
"Check file recovery status" reports "File recovery changed. Refresh the
task".

**Paperclip version or commit**

Observed on build `8812c19`. The regressions fail on master `0f5ce5050e`
before the production edits.

## What Changed

- `WorkspaceExportRecovery`: bind the recovery record by action, run and
schema identity, not by inspection status. Inspection status now gates
only the physical "Recover saved files" request (and the request
re-checks it before POST).
- `WorkspaceExportRecovery`: report through `onRecoveryControlChange`
whether the panel offers its own control. It reports `true` only while
the source is loading or verified and the files are not yet recovered,
or while it shows the previous-server stop confirmation.
- `IssueDetail`: hide the task Retry for retained-export holds only
while the panel reports that it owns the control. Otherwise the existing
task Retry (`POST /issues/:id/retry`) shows. The server renews a bounded
recovery generation and re-verifies the source.
- Show recovered files as recovered even when inspection no longer
matches after recovery.
- Name an unverifiable source in plain words (with the code for other
reasons). State that Paperclip does not apply files it can't verify.
- Recovery text never asks a person to inspect a sandbox or environment,
and never names an owner or administrator as the one who must act. This
covers the retained-file panel, the run source inspector, the copyback
wait, and the native cleanup quarantine message. The execution blocker
and the recovery action read model describe a spent recovery budget from
its current state, so text saved by older releases ("If Retry keeps
failing, inspect the run and its environment") is not shown.
- Task Retry uses the existing `createUuid()` helper, so it works on
plain-HTTP previews without `crypto.randomUUID`. An accepted Retry also
refreshes the inspected file-recovery state. After a failed accepted
request, the unverified-source reason stays visible; it hides only while
recovery runs.
- When the recovery record was replaced, "Check file recovery status"
reloads the task detail instead of telling the user to refresh.
- Tests: component regressions for an old-release hold, a recovered hold
with a mismatched source, verified ownership, and a replaced record. A
page regression proves Retry is shown and calls `retryTask` for
unverified and stale sources. A server safety test proves that Retry for
the old-release hold shape is accepted, opens one new bounded
generation, queues file recovery again, keeps the hold active, and is
idempotent.

## Verification

- RED (master `0f5ce5050e` production files, new tests): 5 component
tests fail. The failure output shows the exact staging text: "Check file
recovery status", "File recovery changed. Refresh the task to inspect
the current recovery.", "The recorded source is unavailable or has
changed." 2 page tests (unverified and stale) fail with `expected [
'Check file recovery status', … ] to include 'Retry'`.
- GREEN: `cd ui && npx vitest run
src/components/WorkspaceExportRecovery.control.test.tsx
src/components/WorkspaceExportRecovery.test.tsx
src/components/RunWorkspaceRecoverySurface.test.tsx
src/components/ExecutionBlockerNotice.test.tsx
src/pages/IssueDetail.test.tsx` passes 320 tests.
- Server: `cd server && npx vitest run src/services/task-retry.test.ts`
passes 81 tests, including the new old-release safety case. This is
safety evidence, not a red: the server path was already correct, and the
defect was in the UI.
- Full UI suite: 11564 pass. 4 tests in `IssuesList.test.tsx` and
`CompanyEnvironments.test.tsx` failed under full-suite load and pass in
isolation (106/106). They are unrelated to this change.
- `pnpm check:token-gates` is clean. `node scripts/run-tsc.mjs -p
ui/tsconfig.json --noEmit` reports no errors in the touched files. The
remaining errors are local artifacts that were not built (plugin SDK UI
entry, generated Storybook catalog).
- Staging was read only (GET requests). The inspect request did not loop
in a fresh tab: two calls in 30 seconds. Most of the tab's request
volume is periodic company issue-list refetches, which this change does
not touch.

## Risks

- Low risk. This is a UI-only behavior change. Server authorization,
source verification, stop receipts, and the bounded recovery policy are
unchanged and remain authoritative.
- The task Retry is now visible for retained-export holds whose source
does not verify. Retry renews one recovery generation. The worker
re-runs the same source-identity checks, so Retry cannot bypass an
ownership refusal. If the refusal is permanent (for example an ambiguous
sandbox allocation), the hold returns to exhausted and the page names
the reason. An automatic fallback for such sources is follow-up work.
- Wording: a parametrized UI test renders every recovery state and
asserts that no administrator, owner, or "inspect the environment" copy
appears. A server test proves that a stored old-release message is
replaced by state-derived text. Both failed before the change.
- Previous-release story: the old-release hold shape (board-owned,
`systemRecovery` revision 5 exhausted with `service_required`,
`workspaceRecovery` blocked and retryable) is covered by component,
page, and server tests. No persisted state shape changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through
Claude Code with tool use (repository search, file edits, shell, test
execution, and a read-only browser).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:24:03 -06:00

312 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <><ReportRecoveryControl owns onChange={onRecoveryControlChange} />
<PreviousServerStopConfirmation key={`${action.id}:${String(action.evidence.runId)}:${retainedOwnerToken}`}
action={action} ownerToken={retainedOwnerToken} issueId={issueId} companyId={companyId} className={className} /></>;
}
if (action && issueId && companyId && isRetainedExport(action, issueId, companyId)) {
return <RetainedFilesRecovery key={`${companyId}:${issueId}:${action.id}:${action.evidence.runId}`}
action={action} issueId={issueId} companyId={companyId} className={className}
onRecoveryControlChange={onRecoveryControlChange} />;
}
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 <><ReportRecoveryControl owns onChange={onRecoveryControlChange} />
<PreviousServerStopConfirmation key={`${action.id}:${String(action.evidence.runId)}`}
action={action} ownerToken={asObject(action.evidence.owner).token as string}
issueId={issueId} companyId={companyId} className={className} /></>;
}
if (!showStatus) return <ReportRecoveryControl owns={false} onChange={onRecoveryControlChange} />;
if (!action || (!ownerRecovery && !isNativeWorkspaceExportRepairCause(action.cause)) || action.ownerType !== "board"
|| !["active", "escalated"].includes(action.status) || typeof action.evidence.runId !== "string")
return <ReportRecoveryControl owns={false} onChange={onRecoveryControlChange} />;
const queued = !!action.evidence.workspaceOwnerStop || action.wakePolicy?.kind === "resume_native_run";
const automatic = queued || (ownerRecovery && asObject(action.evidence.workspaceOwnerRecovery).state === "resuming");
return <><ReportRecoveryControl owns={false} onChange={onRecoveryControlChange} />
<p role="status" className={cn("text-sm text-muted-foreground", className)}>
{automatic ? "Restoring saved work…" : "Saved work is not recovered yet. The agent’s result is saved."}
</p></>;
}
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 <div className={cn("flex flex-col gap-2 text-sm", className)} data-testid="previous-server-stop">
<p role="status">{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."}</p>
{confirm.isSuccess ? null : !canConfirm
? <p className="text-xs text-muted-foreground">{accessView.kind === "loading" || accessView.kind === "reconnecting"
? "Checking access…" : "Paperclip is waiting to confirm that the previous server stopped."}</p>
: !open ? <Button variant="outline" size="sm" className="self-start" onClick={() => setOpen(true)}>
Confirm the previous server stopped</Button>
: <form className="flex flex-col gap-2" onSubmit={event => { event.preventDefault(); if (ready && !confirm.isPending) confirm.mutate(); }}>
<label htmlFor={`previous-server-stop-${action.id}`} className="text-xs text-muted-foreground">
How did you confirm that the previous server and its file copy stopped?</label>
<Textarea id={`previous-server-stop-${action.id}`} value={evidence} onChange={event => setEvidence(event.target.value)}
placeholder="For example: the old server process exited and was not restarted." maxLength={12000} rows={3} />
<div className="flex items-center justify-between gap-2">
<Button type="button" variant="ghost" size="sm" disabled={confirm.isPending} onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" size="sm" disabled={!ready || confirm.isPending}>
{confirm.isPending ? "Confirming…" : "Confirm and resume"}</Button>
</div>
</form>}
{confirm.error ? <p role="alert" className="text-sm text-destructive">
{confirm.error instanceof Error ? confirm.error.message : "The confirmation was not accepted. Refresh the task and try again."}</p> : null}
</div>;
}
type RecoveryInput = { actionId: string; runId: string };
/** Plain wording for a source inspection that did not verify. The code stays visible for support. */
function unverifiedSourceText(inspected: WorkspaceSourceInspection | undefined): string {
if (!inspected || inspected.status === "verified") return "";
if (inspected.status === "not_inspected")
return "Paperclip can’t check the saved files’ source here because the run recorded too many sources.";
const code = inspected.sourceVerification?.reason ?? inspected.failureCode;
return code === "allocation_ownership_ambiguous"
? "Paperclip can’t confirm that this run alone used the sandbox that holds the saved files."
: `Paperclip can’t verify the saved files’ recorded source (${code}).`;
}
function RetainedFilesRecovery({ action, issueId, companyId, className, onRecoveryControlChange }: {
action: IssueRecoveryAction; issueId: string; companyId: string; className?: string;
onRecoveryControlChange?: (owns: boolean) => void;
}) {
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<string | null>(null);
const access = useQuery({ queryKey: queryKeys.access.currentBoardAccess,
queryFn: () => accessApi.getCurrentBoardAccess() });
const accessView = useQueryView(access);
const accessReady = accessView.kind === "ready" || accessView.kind === "stale";
const canMutate = Boolean(accessReady && 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 });
const sourceView = useQueryView(source);
const sourceReady = sourceView.kind === "ready" || sourceView.kind === "stale";
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;
// The recovery record is bound by its own identity. Source inspection is a
// separate read-only probe: it gates only the physical file-recovery request.
// It legitimately reports "blocked" after files were recovered, or when a
// sandbox predates ownership receipts; the recorded state stays authoritative.
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 ? current : null;
}
const sourceVerified = (data: typeof source.data) => Boolean(boundAction(data)) && data?.sourceInspection?.status === "verified";
const current = sourceReady ? boundAction(source.data) : null;
const verified = sourceReady && sourceVerified(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) => { // query-error-ok: recovery requests require an explicit action and receipt reconciliation.
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 (!sourceVerified(observed.data))
throw new Error(unverifiedSourceText(observed.data?.sourceInspection) || "The saved files’ source could not be verified.");
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 () => { // query-error-ok: operator checks run once; the source read uses the shared query retry policy.
const observed = await source.refetch();
if (observed.error) throw observed.error;
const fresh = boundAction(observed.data);
if (!fresh) {
// The task's current recovery replaced this record. Reload the task so it
// shows that recovery, instead of asking for a refresh that changes nothing.
invalidate();
throw new Error("This file recovery was replaced. Showing the task’s 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 checkError = isTransientError(check.error) ? null : check.error;
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);
const sourceLoading = sourceView.kind === "loading" || sourceView.kind === "reconnecting";
// A verified source makes this panel the governed recovery path. Otherwise the
// task's own Retry (renewed, re-verified recovery) remains the next action.
const ownsRecoveryControl = canMutate && (sourceLoading || Boolean(current && verified && !saved));
// `waiting` already excludes a failed accepted request, so the reason stays
// visible after a failed attempt and hides only while recovery is running.
const unverified = Boolean(current && !verified && !saved && !waiting);
return <div className={cn("flex flex-col gap-2 text-sm", className)} data-testid="retained-file-recovery">
<ReportRecoveryControl owns={ownsRecoveryControl} onChange={onRecoveryControlChange} />
<p role="status">{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."}</p>
{canMutate ? <>
{current && verified && retryable && !waiting && (!pending || unconfirmed && checkedUnconfirmed || confirmedFailedAttempt) ? <Button variant="outline" size="sm"
className="self-start" disabled={request.isPending || check.isPending}
onClick={() => request.mutate(confirmedFailedAttempt)}>{request.isPending ? "Requesting file recovery…"
: confirmedFailedAttempt ? "Try file recovery again" : "Recover saved files"}</Button> : null}
{waiting || unconfirmed || failedReceipt || source.error || sourceReady && !current ? <Button variant="ghost" size="sm" className="self-start"
disabled={request.isPending || check.isPending} onClick={() => check.mutate()}>
{check.isPending ? "Checking file recovery…" : "Check file recovery status"}
</Button> : null}
{sourceView.kind === "loading" || sourceView.kind === "reconnecting" ? <p className="text-xs text-muted-foreground">Inspecting the recorded file source…</p> : null}
{sourceView.kind === "error" || checkError || notice ? <p role="alert" className="text-sm text-destructive">
{notice ?? (checkError instanceof Error ? checkError.message : "File recovery could not be inspected. Check its status again.")}
</p> : null}
{current && recovery.status === "blocked" && (serverAcknowledged || recovery.retryable !== true)
? <p role="alert">File recovery has not finished. The saved files remain protected and are not applied.</p> : null}
{unverified ? <p role="status" className="text-muted-foreground">
{unverifiedSourceText(source.data?.sourceInspection)} Paperclip does not apply files it can’t verify.
</p> : null}
{sourceReady && !current ? <p role="alert">This file recovery was replaced. Check its status to show the task’s current recovery.</p> : null}
</> : accessView.kind === "error" ? <p role="alert" className="text-sm text-destructive">Task access could not be checked. Refresh before requesting file recovery.</p>
: accessView.kind === "loading" || accessView.kind === "reconnecting" || !owner ? <p role="status" className="text-xs text-muted-foreground">Checking file recovery access…</p>
: <p className="text-xs text-muted-foreground">A board member with task access can request file recovery.</p>}
</div>;
}