Files
PaperClipAI/ui/src/components/WorkspaceExportRecovery.tsx
T
DottaandPaperclip 519b345d69 fix(ui): expose governed retained-file recovery (#18848)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The control plane must preserve saved work before it admits another
attempt.
> - Retained-file recovery already has an authorized API and a bounded
worker.
> - The task UI hid that operation when workspace repair blocked
execution.
> - The run inspector could check source identity but could not request
preservation.
> - This pull request exposes that existing operation with its exact
source binding.
> - The worker still owns file verification, stop receipts, and
execution release.

## Linked Issues or Issue Description

Refs #18678 and #18803 for the existing retained-source and continuation
contracts.

**What happened?**

A failed native run could leave an active, system-owned export recovery
action. The task showed a generic Retry control. Its workspace repair
gate correctly refused another provider turn. The run inspector offered
only read-only source inspection. The authorized file-preservation API
had no production UI caller.

**Expected behavior**

An authorized board operator can request preservation of the exact
retained files. The UI keeps the repair hold until the server records
verified preservation. A lost response must keep the same request
identity.

**Steps to reproduce**

1. Use an active retained-export action whose workspace recovery is
blocked and retryable.
2. Open its source task while `workspaceRepairRequired` is true.
3. Open the exact failed run and inspect recovery.
4. Before this change, neither surface can request the existing
file-preservation operation.

**Paperclip version or commit**

The focused regression fails on master
`1f55073f96b2485f49f98ee8c261aa14ec2f79ac` before the production UI
edits.

I checked the roadmap and searched open public PRs for this UI repair.
No duplicate was found. This is a repair to the existing recovery
capability.

## What Changed

- Add Recover saved files to the existing task recovery area and
exact-run inspector. Reuse the current board task-mutation access check
and the existing `recoverWorkspace` API.
- Read the exact action, run, company, task, and source observation
again before sending. Refuse changed, retired, foreign, unverified, or
non-retryable sources.
- Save one account-scoped request before POST. Keep its key through a
lost response and reload. Reconcile with GET before an explicit resend.
Use a new key only after an explicit click and a fresh, confirmed
exhausted attempt; do not resend automatically.
- Show queued, recovering, blocked, and verified receipt states
separately. A request acknowledgement does not mark files saved or
resolve the task hold. Hide generic Retry for native export holds. Keep
the existing Retry path for legacy restoration.
- Cover both mounted surfaces and negative access, source, account,
persistence, duplicate-click, and lost-response cases. Update the
existing design guidance and correct the affected run-detail recipe
only.

## Verification

- RED: the new component regression fails with `missing Recover saved
files` before production changes. The original failure is retained.
- GREEN: 265 tests pass across
`WorkspaceExportRecovery.control.test.tsx`,
`WorkspaceExportRecovery.test.tsx`,
`RunWorkspaceRecoverySurface.test.tsx`, and the full
`IssueDetail.test.tsx` suite.
- Review RED/GREEN: the prior UI reused the failed key after a confirmed
exhausted attempt. A focused regression now proves a different key for
an explicit new attempt, while the lost-response controls still prove
key reuse. Mounted task controls prove that verified native preservation
stays available, unverified or stale native sources never offer generic
Retry, and viewers cannot mutate. A separate RED against the exact prior
UI proves the lost legacy control. Its GREEN clicks the existing legacy
Retry API while the workspace hold remains active.
- Full UI TypeScript check passes: `node scripts/run-tsc.mjs -p
ui/tsconfig.json --noEmit`.
- `node scripts/check-token-gates.mjs`, `node scripts/check-maps.mjs`,
and `git diff --check` pass. The map check proves structure only.
- One early legacy test probe used an unsigned fixture and a readonly
array; those setup failures are retained. The corrected same-user
fixture proves the source RED and GREEN. The first test command used an
unnamed project filter and stopped before loading tests. The corrected
package configuration produced the recorded RED. One initial storage
fault probe did not intercept jsdom's storage method; the corrected
prototype fault proves that unavailable storage sends no POST.
- Dependencies use Node 24.22.2 and a frozen pnpm 9.15.4 install with
scripts disabled. The maintained shared and SDK prerequisite builds pass
on this base. No dependency or lockfile changes are included.
- A later Paperclip Review follow-up found the plain-HTTP UUID gap. Its
focused regression fails before POST with the prior direct call, then
passes with the existing UUID helper. The same storage, lost-response,
retry and source controls remain green.
- Original full CI `41715060582` fails two Cloud-auth task-entry browser
checks at their existing heading assertion. The retained original
artifacts show a late task pane (one already contains the heading; one
still shows Loading conversation). The cause remains unproven. No auth,
fixture, timeout or assertion change is included. Fresh Greptile is 5/5
with zero open threads and Paperclip Review passes on the UUID fix head.
Current full CI `41720068249` attempt 1 failed. Three workers were
terminated by AWS Spot capacity loss. The CLI startup and dense-ZIP
checks also timed out at their unchanged deadlines. The CLI/shared input
trees match a previous passing CI job, and the dense-ZIP case passes one
focused local check. The CLI timeout cause remains unknown. The
coordinator requested one bounded recheck. Attempt 2 succeeds on the
same source, with all current checks green. No source, fixture, timeout
or assertion changed for that recheck. The worker issued no retry. This
pass does not establish the original CLI timeout cause.
- Full recursive typecheck, repository tests, and application build were
not run locally under the bounded UI verification scope. Full CI is
requested.
- No live preservation request, provider turn, deployment, or sandbox
cleanup was performed for this repair. Original saved-file recovery
remains unverified until rollout and real acceptance.

## Risks

The client access check is a display guard. The existing server
authorization and physical source, ownership, stop, and snapshot checks
remain authoritative. Unavailable browser storage blocks the request
before POST. A failed acknowledgement retains the request for its owner.
The UI never calls task Retry, recovery resolution, reconstruction,
provider execution, or destructive cleanup. The existing worker retains
its bounded recovery policy and may finish the saved result after real
preservation.

## Model Used

OpenAI Codex, GPT-6 family, with repository inspection, code editing,
and test execution. The exact runtime model ID and context-window size
are not exposed to this agent.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-03 16:00:03 -06:00

176 lines
12 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 { 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 <RetainedFilesRecovery key={`${companyId}:${issueId}:${action.id}:${action.evidence.runId}`}
action={action} issueId={issueId} companyId={companyId} className={className} />;
}
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 <p role="status" className={cn("text-sm text-muted-foreground", className)}>
{automatic ? "Restoring saved work…" : "Workspace recovery needs operator assistance. 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;
}
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<string | null>(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 <div className={cn("flex flex-col gap-2 text-sm", className)} data-testid="retained-file-recovery">
<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 && 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.isError || source.data && !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}
{source.isPending ? <p className="text-xs text-muted-foreground">Inspecting the recorded file source…</p> : null}
{source.isError || check.isError || notice ? <p role="alert" className="text-sm text-destructive">
{notice ?? (check.error instanceof Error ? check.error.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 source remains protected; service assistance is needed.</p> : null}
{source.data && !current ? <p role="alert">The recorded source is unavailable or has changed. Inspect recovery before continuing.</p> : null}
</> : access.isError ? <p role="alert" className="text-sm text-destructive">Task access could not be checked. Refresh before requesting file recovery.</p>
: access.isPending || !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>;
}