mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip manages agents and their work. > - Recovery must preserve execution ownership and accepted results. > - A saved PID proves nothing outside its recorded process namespace. > - Uncertain unfinished work must stay held until its original owners stop. > - Operators need a source-bound containment action and a separate ordinary Retry. > - Accepted results must use the existing finalizer without another provider turn. > - This PR keeps those paths safe, recoverable, and quiet between real obligations. ## Linked Issues or Issue Description Refs: #19713 Refs: #19591 Recovery could use a local PID to release or replace a controller from another process namespace. Connected-chat Retry also rejected successful containment on a task in `todo`. The new containment action used a broader permission than the existing owner-stop action. A later review found that result-less guards also rejected the existing accepted-result confirmation action. ## What Changed - Record and verify the controller's kernel/PID namespace. Keep unknown and foreign unfinished ownership held. Preserve independent durable stop evidence and the existing remote lease fences. - Use the shared accepted-result ownership reconciler. Bind settlement to the observed lease token, generation, boot, PID, namespace and process birth. Retain finalizer, cleanup, current-controller, live-owner and remote exclusions. - Share the remote-owner reader across recovery consumers. Scope runner leases to the same company, task and run. Do not make local PID probes for remote accepted-result ownership. - Gate original-host containment on `runtime:confirm_owner_stop`. Bind it to the current task, source, assignee, original expiry, lease and Stop state. Audit separate controller/provider containment and reviewed action outcomes. - Keep containment separate from ordinary Retry. Use one canonical committed receipt and the normal admission, budget, workspace and lineage checks. Preserve the recorded external-action outcomes. - Keep the controller-only confirmation actionable for both result-less work and accepted results. Result-less work gets its next source-bound attention in the same transaction. Accepted results keep their original result and use normal shared settlement/finalization. - Keep identity inspection quiet. Preserve the original foreign-owner expiry and use the existing scoped finalizer in accepted-result recovery controls. - Update the recovery card, validators, execution projection, docs and access maps. Reuse existing canonical columns; add no migration or ownership backfill. ## Verification Current publication head: `71ea76b568de09d6313d8992e803131d9d5c54f3`. **The current fix has a causal red/green check.** On the test-only revision, both foreign-local stopped/fenced controller cases failed because recovery probed saved runner, process-group and provider PIDs in this host's namespace. This measured the wrong probes; it did not run duplicate providers. After the fix, the same two cases passed every no-probe, no-claim, no-controller-stamp, no-stop-receipt and unchanged-authority assertion. Three existing remote preparation, grace and fenced-controller progress cases also passed. Total: **5 passed, 0 failed;170 filtered rows excluded**. All three normal affected App TypeScript programs passed on this head (660,681 and428 roots). The fix separates a controller wait from execution stop proof. Independent live/grace evidence still schedules recovery. A stopped/fenced foreign controller can pass this namespace guard only for remote execution, which keeps its later checkpoint and never-launched checks. Foreign local execution stays held before runner or provider PID inspection. Earlier source-scoped results remain evidence, not fresh executions on the current head: - On `c86dd741`, the complete failed-owner file passed **25/25**, with no skips or filters. Empty scans wrote no controller stamps. Foreign ownership kept its original expiry. Normal returned-claim settlement and attention at the exact deadline passed. - On `c86dd741`, **15 selected accepted-result/restart controls passed**. The81 filtered rows are excluded. Both completion cases used the existing scoped finalizer and kept their accepted result without another provider turn. - Trusted CI at synthetic merge `50d072a5` (actual master `1b7fe334` plus `c86dd741`) passed the complete **17-case maintenance-server file**. This includes the original90-real-second quiet observation and two qualified hourly windows. The quiet case took118,918ms. The four jobs that failed on the earlier publication all passed on that synthetic merge. - On `6243b32c`, the complete ownership-report file passed **29/29**. Native NULL namespace stayed unprovable and generic compatibility remained. Three selected recovery controls passed;170 filtered rows are excluded. - On `f81f2c3c`,356 business cases passed: whole ownership-route221, recoveryUI70, owner-proof38 and controller-confirmation8; selected accepted-result authority18; and one real connected-chat containment, separate authorized Retry, useful Native result and downstream reply case. The2,024 filtered rows are excluded. A separate earlier accepted-result causal case kept the original result and completed after restart without replay. Normal Shared exports, App programs, UI typecheck, token gates and map checks passed on their recorded revisions. Unchanged DB, adapter, Eval and Runner inputs/outputs have explicit carry proofs. The clean normal merge with fetched master `8fe524f7` preserves all31 exclusive owned paths and14,827 unrelated master entries. The nine overlapping paths were reviewed, including the upstream unidentified-run stop action. Fresh CI must validate that actual merge composition. PG cases used existing external PG17, fresh owned databases, pools at most4, one worker and no SQL observer during cases. Registered clients and recorded process groups closed. Independent final SQL audits found zero campaign sessions before normal database cleanup. Original failing fixtures remain retained. Original failures are preserved. One new test first failed compilation due to a missing getter import; an import-only correction passed its full owning programs before the causal red/green check. A local quiet attempt stopped in the normal prerequisite hook before any business case and its collector rejected two normal build markers. The later17-case CI pass establishes quiet recovery; it does not turn that failed local collector into a pass. An earlier useful case passed before its private cleanup collector failed on an owned read-only directory; normal cleanup completed after all owners closed. Earlier runner-shutdown cancellation and a separate private whole-suite failure remain unexplained. Current-head checks are complete:57 success,1 neutral and3 skipped, including the successful aggregate CI gate. The legacy status passed. Greptile is5/5 and Ponytail gives an explicit5/5 Ship verdict for this head. All seven review threads are resolved. Full recursive typecheck, the full test suite and release build were not repeated locally. No result here proves deployed suspension, a live provider or termination on another host. ## Risks - Unknown physical ownership can require an owner/admin action. A false containment statement can be unsafe. - New work, reassignment, Stop, renewed authority, accepted results or pending cleanup can invalidate an action. The operator must refresh. - Controller confirmation does not prove provider/tool containment. Completed external actions must not be replayed. - Generic legacy and child-process compatibility remains separate from the strict Native controller policy. - Existing saved state and canonical schema are preserved. Permanent safety refusals remain held with their normal named recovery path. ## Model Used OpenAI `gpt-6.1-sol` agents implemented and tested the changes with reasoning, repository tools and code execution. A GPT-6-based Codex managing agent reviewed and integrated them. The host does not expose the manager's exact build or the context-window sizes. ## 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 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
383 lines
27 KiB
TypeScript
383 lines
27 KiB
TypeScript
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;
|
||
}
|
||
|
||
const CONTROLLER_HOST_UNVERIFIED = "native_controller_host_unverified";
|
||
const OWNER_UNPROVABLE = "ownership_owner_unprovable";
|
||
|
||
/** What the stuck record is, by ownership kind, for the board's confirmation. */
|
||
const OWNER_GONE_RESOURCE: Record<string, { what: string; releasing: string }> = {
|
||
environment_run_lease_active: { what: "A sandbox lease from this task’s earlier run", releasing: "Releasing the sandbox lease…" },
|
||
legacy_controller_lease: { what: "This task’s earlier run", releasing: "Recovering the run…" },
|
||
environment_teardown_claim: { what: "A sandbox cleanup from this task’s earlier run", releasing: "Resuming the sandbox cleanup…" },
|
||
sandbox_creation_intent: { what: "A sandbox this task’s earlier run was creating", releasing: "Reconciling the sandbox…" },
|
||
workspace_runtime_control_operation: { what: "A workspace service change from this task’s earlier run", releasing: "Releasing the workspace…" },
|
||
native_workspace_finalize_attempt: { what: "A workspace save from this task’s earlier run", releasing: "Releasing the workspace save…" },
|
||
native_finalizer_lease: { what: "The result handling of this task’s earlier run", releasing: "Resuming the result handling…" },
|
||
native_cleanup_lease: { what: "The cleanup of this task’s earlier run", releasing: "Resuming the cleanup…" },
|
||
};
|
||
const OWNER_GONE_DEFAULT = { what: "A resource from this task’s earlier run", releasing: "Releasing the resource…" };
|
||
|
||
/** Causes whose one action is the board's previous-server stop confirmation;
|
||
* the generic recovery card must not offer other resolutions for them. */
|
||
export function isPreviousServerStopCause(cause: string | null | undefined) {
|
||
return cause === "native_workspace_finalization_owner_unverified" || cause === CONTROLLER_HOST_UNVERIFIED
|
||
|| cause === OWNER_UNPROVABLE;
|
||
}
|
||
|
||
/** 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} />;
|
||
}
|
||
// An ephemeral server cannot judge a pre-upgrade controller row by PID.
|
||
if (action?.cause === CONTROLLER_HOST_UNVERIFIED && issueId && companyId
|
||
&& isControllerStopRequired(action, issueId, companyId)) {
|
||
return <><ReportRecoveryControl owns onChange={onRecoveryControlChange} />
|
||
<PreviousServerStopConfirmation key={`${action.id}:${String(action.evidence.runId)}`}
|
||
action={action} issueId={issueId} companyId={companyId} className={className}
|
||
confirmStop={stopEvidence => issuesApi.confirmControllerStopped(issueId, {
|
||
actionId: action.id, runId: action.evidence.runId as string, stopEvidence })}
|
||
copy={{ waiting: "This run’s previous server was recorded before Paperclip stored which host it ran on, so Paperclip can’t verify that it stopped. The run’s saved state is kept.",
|
||
question: "How did you confirm that the previous server stopped?",
|
||
placeholder: "For example: the old server container was replaced and is no longer running.",
|
||
resuming: "Controller stop recorded. Checking recovery…" }} /></>;
|
||
}
|
||
// A resource held by a server recorded before Paperclip stored which server held it.
|
||
if (action?.cause === OWNER_UNPROVABLE && issueId && companyId && isOwnerGoneRequired(action, issueId, companyId)) {
|
||
const resource = OWNER_GONE_RESOURCE[String(action.evidence.kind)] ?? OWNER_GONE_DEFAULT;
|
||
return <><ReportRecoveryControl owns onChange={onRecoveryControlChange} />
|
||
{/* Keyed by the record version: a request refreshed to a newer version resets an open form, so
|
||
the board never confirms a version it was not shown. */}
|
||
<PreviousServerStopConfirmation key={`${action.id}:${String(action.evidence.recordId)}:${String(action.evidence.recordUpdatedAt ?? "")}`}
|
||
action={action} issueId={issueId} companyId={companyId} className={className}
|
||
confirmStop={stopEvidence => issuesApi.confirmOwnerGone(issueId, {
|
||
actionId: action.id, recordId: action.evidence.recordId as string, stopEvidence,
|
||
// The version shown here: a request refreshed since must be confirmed again.
|
||
...(typeof action.evidence.recordUpdatedAt === "string" ? { recordVersion: action.evidence.recordUpdatedAt } : {}) })}
|
||
copy={{ waiting: `${resource.what} was recorded before Paperclip stored which server held it, so Paperclip can’t verify that the server stopped. It stays reserved until then.`,
|
||
question: "How did you confirm that the previous server stopped?",
|
||
placeholder: "For example: the old server container was replaced and is no longer running.",
|
||
resuming: resource.releasing }} /></>;
|
||
}
|
||
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 isControllerStopRequired(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" && !action.evidence.controllerStop;
|
||
}
|
||
|
||
function isOwnerGoneRequired(action: IssueRecoveryAction, issueId: string, companyId: string) {
|
||
return action.companyId === companyId && action.sourceIssueId === issueId
|
||
&& action.ownerType === "board" && ["active", "escalated"].includes(action.status)
|
||
&& typeof action.evidence.recordId === "string" && !action.evidence.ownerGone;
|
||
}
|
||
|
||
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. */
|
||
export function PreviousServerStopConfirmation({ action, runId: givenRunId, formId: givenFormId, ownerToken, issueId, companyId, className, confirmStop, copy }: {
|
||
action?: IssueRecoveryAction; runId?: string; formId?: string; issueId: string; companyId: string; className?: string;
|
||
/** Saved-work copyback owner; used when no custom confirmation is given. */
|
||
ownerToken?: string;
|
||
confirmStop?: (stopEvidence: string) => Promise<unknown>;
|
||
copy?: { waiting: string; question: string; placeholder: string; resuming: string; confirm?: string };
|
||
}) {
|
||
const runId = givenRunId ?? action!.evidence.runId as string;
|
||
const formId = givenFormId ?? action!.id;
|
||
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: () => confirmStop ? confirmStop(evidence.trim())
|
||
: issuesApi.resumeWorkspaceFinalization(issueId, { actionId: action!.id, runId, ownerToken: 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 ? copy?.resuming ?? "Restoring saved work…"
|
||
: copy?.waiting ?? "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)}>
|
||
{copy?.confirm ?? "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-${formId}`} className="text-xs text-muted-foreground">
|
||
{copy?.question ?? "How did you confirm that the previous server and its file copy stopped?"}</label>
|
||
<Textarea id={`previous-server-stop-${formId}`} value={evidence} onChange={event => setEvidence(event.target.value)}
|
||
placeholder={copy?.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>;
|
||
}
|