feat(ui): workspace-validation diagnosis card from run evidence (PAP-13578)

PAP-13568 Phase 4b. Render a workspace-validation diagnosis card on the run
page from the run's own `resultJson.workspaceValidation` evidence even when the
source issue has no live recovery action (blocked / pending-interaction issues,
per L3) — a failed workspace-validation run never renders nothing.

- Enrich the shared IssueRecoveryActionCard: lead with the plain-language reason,
  expected-vs-found branches with ahead/behind counts (read defensively — optional
  until Phase 2 lands them), and parked-by provenance (which issue/run left the
  worktree on the live branch).
- New lossless "Restore recorded branch" CTA (Contract A) for a clean divergence,
  wired to a new `restore` reconcile mode; the parked branch keeps its commits.
  Hidden when the recorded branch is contended (steer to isolated re-issue). The
  dangerous `override` stays demoted behind its reason-prompt popover.
- Run-page fallback reuses the same card via a presentational synthetic action
  built from run evidence; no resolve menu (no persisted action to resolve).
- Thread the restore handler through IssueDetail → IssueChatThread so the issue
  thread card gains the same enrichment.
- Component tests (50) + a Storybook panel for the card states; screenshots on
  the issue.

Coordinates with Phase 2 (PAP-13575): calls
POST /execution-workspaces/:id/reconcile-branch { mode: "restore" } and reads
optional provenance.aheadCount/behindCount. UI-only; no server files touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-07-11 12:54:41 +00:00
1 parent e4e12bfb89
commit 8c082f84e2
9 files changed
+819 -46

No files matched your search

+10 -1
View File
@@ -131,9 +131,18 @@ export const executionWorkspacesApi = {
* `runtime:manage` permission, and requires a non-empty operator `reason`.
* - `mode: "quarantine_restore"` — lossless dirty-worktree repair; the server quarantines the
* dirty changes onto a rescue branch and restores the recorded branch. No `reason` needed.
* - `mode: "restore"` — lossless *clean*-divergence repair (PAP-13568 Contract A). Restores the
* recorded branch in a clean, anchored, uncontended worktree parked on a foreign branch that is
* itself a real local ref; the parked branch keeps its commits (nothing is discarded). No
* `reason` needed. The server side lands in PAP-13568 Phase 2 (PAP-13575); this client is
* additive and forward-compatible with that route.
*/
reconcile: (
id: string,
body: { mode: "forward" } | { mode: "override"; reason: string } | { mode: "quarantine_restore" },
body:
| { mode: "forward" }
| { mode: "override"; reason: string }
| { mode: "quarantine_restore" }
| { mode: "restore" },
) => api.post<ExecutionWorkspace>(`/execution-workspaces/${id}/reconcile-branch`, body),
};
+6
View File
@@ -443,6 +443,8 @@ interface IssueChatThreadProps {
onBreakGlassOverrideRecoveryAction?: (reason: string) => void;
onQuarantineRestoreRecoveryAction?: () => void;
quarantineRestoreRecoveryActionPending?: boolean;
onRestoreRecordedBranchRecoveryAction?: () => void;
restoreRecoveryActionPending?: boolean;
canBreakGlassRecoveryAction?: boolean;
reconcileRecoveryActionPending?: boolean;
canFalsePositiveRecoveryAction?: boolean;
@@ -4198,6 +4200,8 @@ export function IssueChatThread({
onBreakGlassOverrideRecoveryAction,
onQuarantineRestoreRecoveryAction,
quarantineRestoreRecoveryActionPending = false,
onRestoreRecordedBranchRecoveryAction,
restoreRecoveryActionPending = false,
canBreakGlassRecoveryAction = false,
reconcileRecoveryActionPending = false,
canFalsePositiveRecoveryAction = false,
@@ -4938,6 +4942,8 @@ export function IssueChatThread({
onBreakGlassOverride={onBreakGlassOverrideRecoveryAction}
onQuarantineRestore={onQuarantineRestoreRecoveryAction}
quarantineRestorePending={quarantineRestoreRecoveryActionPending}
onRestoreRecordedBranch={onRestoreRecordedBranchRecoveryAction}
restorePending={restoreRecoveryActionPending}
canBreakGlass={canBreakGlassRecoveryAction}
reconcilePending={reconcileRecoveryActionPending}
canFalsePositive={canFalsePositiveRecoveryAction}
@@ -704,3 +704,115 @@ describe("IssueRecoveryActionCard repair workspace (quarantine_restore)", () =>
expect(node.querySelector("[data-testid='recovery-action-repair-trigger']")).not.toBeNull();
});
});
describe("IssueRecoveryActionCard restore recorded branch (PAP-13568 Phase 4b)", () => {
it("offers the restore action only for a clean divergence, not a dirty one", () => {
const cleanNode = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({ workspaceValidation: { cleanliness: "clean" } })}
onRestoreRecordedBranch={() => {}}
/>,
);
expect(cleanNode.querySelector("[data-testid='recovery-action-restore-trigger']")).not.toBeNull();
const dirtyNode = render(
<IssueRecoveryActionCard action={buildDirtyDivergenceAction()} onRestoreRecordedBranch={() => {}} />,
);
expect(dirtyNode.querySelector("[data-testid='recovery-action-restore-trigger']")).toBeNull();
});
it("does not offer the restore action without a handler or for non-workspace kinds", () => {
const noHandler = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({ workspaceValidation: { cleanliness: "clean" } })}
/>,
);
expect(noHandler.querySelector("[data-testid='recovery-action-restore-trigger']")).toBeNull();
const nonWorkspace = render(
<IssueRecoveryActionCard action={buildAction()} onRestoreRecordedBranch={() => {}} />,
);
expect(nonWorkspace.querySelector("[data-testid='recovery-action-restore-trigger']")).toBeNull();
});
it("hides restore when the recorded branch is contended (steer to isolated re-issue instead)", () => {
const node = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({
workspaceValidation: {
cleanliness: "clean",
contention: {
claimedByWorkspaceId: "ws-99",
claimedByIssueId: "issue-99",
claimedByIssueIdentifier: "PAP-9001",
activeRun: null,
},
},
})}
onRestoreRecordedBranch={() => {}}
onReissueIsolated={() => {}}
/>,
);
expect(node.querySelector("[data-testid='recovery-action-restore-trigger']")).toBeNull();
expect(node.querySelector("[data-testid='recovery-action-reissue-trigger']")).not.toBeNull();
});
it("confirm popover restates the kept parked branch + recorded branch, then fires the handler (no reason field)", () => {
const onRestoreRecordedBranch = vi.fn();
const node = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({ workspaceValidation: { cleanliness: "clean" } })}
onRestoreRecordedBranch={onRestoreRecordedBranch}
/>,
);
click(node.querySelector("[data-testid='recovery-action-restore-trigger']"));
const restated = document.body.querySelector("[data-testid='recovery-restore-restated']");
const text = restated?.textContent ?? "";
expect(text).toContain("nleach/PAP-1405-live");
expect(text).toContain("kept");
expect(text).toContain("PAP-522-recorded");
// Lossless — no reason textarea.
expect(document.body.querySelector("textarea")).toBeNull();
click(document.body.querySelector("[data-testid='recovery-action-restore-confirm']"));
expect(onRestoreRecordedBranch).toHaveBeenCalledTimes(1);
});
it("disables the restore trigger while a restore is pending", () => {
const node = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({ workspaceValidation: { cleanliness: "clean" } })}
onRestoreRecordedBranch={() => {}}
restorePending
/>,
);
expect(
node.querySelector<HTMLButtonElement>("[data-testid='recovery-action-restore-trigger']")?.disabled,
).toBe(true);
});
it("renders ahead/behind counts and the parked-by provenance when present", () => {
const node = render(
<IssueRecoveryActionCard
action={buildWorkspaceValidationAction({
workspaceValidation: {
cleanliness: "clean",
aheadCount: 6,
behindCount: 59,
parkedByIdentifier: "PAP-13359",
parkedByRunId: "2ef32c67ffff",
},
})}
/>,
);
expect(node.querySelector("[data-testid='recovery-ahead-count']")?.textContent).toContain("6 ahead");
expect(node.querySelector("[data-testid='recovery-behind-count']")?.textContent).toContain("59 behind");
const parkedBy = node.querySelector("[data-testid='recovery-parked-by']")?.textContent ?? "";
expect(parkedBy).toContain("PAP-13359");
expect(parkedBy).toContain("2ef32c67");
});
it("omits the ahead/behind row when counts are absent (pre-Phase-2 evidence)", () => {
const node = render(<IssueRecoveryActionCard action={buildWorkspaceValidationAction()} />);
expect(node.querySelector("[data-testid='recovery-ahead-behind']")).toBeNull();
});
});
+224 -11
View File
@@ -15,6 +15,7 @@ import {
Lock,
OctagonAlert,
RefreshCw,
RotateCcw,
Sparkles,
TriangleAlert,
Wrench,
@@ -101,7 +102,16 @@ export interface IssueRecoveryActionCardProps {
onQuarantineRestore?: () => void;
/** Whether a quarantine-restore repair is currently in flight (shares the reconcile spinner). */
quarantineRestorePending?: boolean;
/** Whether a reconcile (forward, override, or quarantine-restore) is currently in flight. */
/**
* Handler for the clean-divergence lossless repair — "Restore recorded branch" (PAP-13568
* Contract A). Rendered only for a *clean* divergence; the caller invokes the S4 reconcile op in
* `restore` mode, which checks out the recorded branch (the parked branch keeps its commits). If
* omitted, the restore action is not shown.
*/
onRestoreRecordedBranch?: () => void;
/** Whether a restore-recorded-branch repair is currently in flight (shares the reconcile spinner). */
restorePending?: boolean;
/** Whether a reconcile (forward, override, quarantine-restore, or restore) is currently in flight. */
reconcilePending?: boolean;
/** Whether the viewer can run destructive board-only actions (e.g. false-positive dismissal). */
canFalsePositive?: boolean;
@@ -265,14 +275,14 @@ function formatShortSha(sha: string | null): string | null {
* live ("actual"/checked-out) branch, both HEAD shas, and a server-computed ancestry verdict +
* plain-language explanation of why the run was declined.
*/
interface WorkspaceContention {
export interface WorkspaceContention {
claimedByIssueId: string | null;
claimedByIssueIdentifier: string | null;
/** True when the claiming workspace has a queued/running run (not just a stale claim). */
hasActiveRun: boolean;
}
interface WorkspaceDivergence {
export interface WorkspaceDivergence {
expectedBranch: string | null;
liveBranch: string | null;
expectedHeadSha: string | null;
@@ -280,6 +290,22 @@ interface WorkspaceDivergence {
ancestryVerdict: GitWorktreeBranchAncestryVerdict | null;
plainLanguageReason: string | null;
cleanliness: "clean" | "dirty" | "unknown" | null;
/**
* How many commits the live (checked-out) branch is ahead of / behind the recorded branch, when
* the server computed them. Optional — older evidence payloads and the pre-PAP-13568-Phase-2
* server omit these, so both stay null and the UI falls back to the ancestry verdict alone.
*/
aheadCount: number | null;
behindCount: number | null;
/**
* Provenance of the parked branch: which issue/run left the worktree on the live branch. The
* source issue that owns the failing run always comes through as `sourceIdentifier`; a distinct
* "parked by" attribution (a *different* run than the one that failed) is optional and only
* present once the server records it.
*/
sourceIdentifier: string | null;
parkedByIdentifier: string | null;
parkedByRunId: string | null;
/** Number of dirty (uncommitted) status entries in the live worktree, when known. */
dirtyFileCount: number | null;
/** Sample of dirty paths (already truncated server-side) for the confirm step. */
@@ -334,9 +360,15 @@ function readContention(value: unknown): WorkspaceContention | null {
};
}
function readWorkspaceDivergence(action: IssueRecoveryAction): WorkspaceDivergence | null {
if (action.kind !== "workspace_validation") return null;
const workspaceValidation = asRecord(action.evidence?.workspaceValidation);
/**
* Reads a git-worktree branch-incoherence divergence from a raw `workspaceValidation` evidence
* payload. This is the same `GitWorktreeBranchIncoherenceEvidence` shape whether it is nested under
* a recovery action's `evidence.workspaceValidation` (issue thread / recovery card) or stamped
* directly on a failed run's `resultJson.workspaceValidation` (run-page fallback card, PAP-13568
* Phase 4b). Returns null when the payload is missing or is not a branch-incoherence failure.
*/
export function readWorkspaceDivergenceFromEvidence(evidence: unknown): WorkspaceDivergence | null {
const workspaceValidation = asRecord(evidence);
if (!workspaceValidation) return null;
if (workspaceValidation.reason !== "git_worktree_branch_incoherence") return null;
const provenance = asRecord(workspaceValidation.provenance) ?? {};
@@ -358,6 +390,19 @@ function readWorkspaceDivergence(action: IssueRecoveryAction): WorkspaceDivergen
ancestryVerdict: asAncestryVerdict(provenance.ancestryVerdict),
plainLanguageReason: asNonEmptyString(provenance.plainLanguageReason),
cleanliness,
// Optional ahead/behind — read from provenance first, then the evidence root, so we tolerate
// either placement once Phase 2 lands them (and stay null before then).
aheadCount:
asNonNegativeInt(provenance.aheadCount) ?? asNonNegativeInt(workspaceValidation.aheadCount),
behindCount:
asNonNegativeInt(provenance.behindCount) ?? asNonNegativeInt(workspaceValidation.behindCount),
sourceIdentifier,
parkedByIdentifier:
asNonEmptyString(workspaceValidation.parkedByIdentifier) ??
asNonEmptyString(provenance.parkedByIdentifier),
parkedByRunId:
asNonEmptyString(workspaceValidation.parkedByRunId) ??
asNonEmptyString(provenance.parkedByRunId),
dirtyFileCount: asNonNegativeInt(workspaceValidation.statusEntryCount),
dirtyPathSample: asStringArray(workspaceValidation.dirtyPathSample),
contention: readContention(workspaceValidation.contention),
@@ -366,6 +411,11 @@ function readWorkspaceDivergence(action: IssueRecoveryAction): WorkspaceDivergen
};
}
function readWorkspaceDivergence(action: IssueRecoveryAction): WorkspaceDivergence | null {
if (action.kind !== "workspace_validation") return null;
return readWorkspaceDivergenceFromEvidence(action.evidence?.workspaceValidation);
}
const ANCESTRY_BADGE: Record<
GitWorktreeBranchAncestryVerdict,
{ label: string; className: string }
@@ -414,7 +464,41 @@ function BranchFacet({
);
}
function DivergenceDiagnosis({
/**
* Renders the ahead/behind commit spread between the live and recorded branches when the server
* computed it. Falls back to nothing (the ancestry badge already summarises the relationship) when
* the counts are absent — e.g. pre-PAP-13568-Phase-2 evidence.
*/
function AheadBehindSummary({ divergence }: { divergence: WorkspaceDivergence }) {
const { aheadCount, behindCount } = divergence;
if (aheadCount === null && behindCount === null) return null;
return (
<div
data-testid="recovery-ahead-behind"
className="flex flex-wrap items-center gap-1.5 text-(length:--text-micro) text-muted-foreground"
>
<span className="uppercase tracking-(--tracking-label)">Live vs. recorded</span>
{aheadCount !== null ? (
<span
data-testid="recovery-ahead-count"
className="inline-flex items-center gap-1 rounded-md border border-border/60 bg-background/60 px-1.5 py-0.5 font-mono text-foreground/80"
>
↑ {aheadCount} ahead
</span>
) : null}
{behindCount !== null ? (
<span
data-testid="recovery-behind-count"
className="inline-flex items-center gap-1 rounded-md border border-border/60 bg-background/60 px-1.5 py-0.5 font-mono text-foreground/80"
>
↓ {behindCount} behind
</span>
) : null}
</div>
);
}
export function DivergenceDiagnosis({
divergence,
dividerClass,
}: {
@@ -422,6 +506,7 @@ function DivergenceDiagnosis({
dividerClass: string;
}) {
const badge = ANCESTRY_BADGE[divergence.ancestryVerdict ?? "unknown"];
const parkedByLabel = divergence.parkedByIdentifier ?? divergence.sourceIdentifier;
return (
<div
data-testid="recovery-divergence-diagnosis"
@@ -444,6 +529,9 @@ function DivergenceDiagnosis({
{badge.label}
</Badge>
</div>
{divergence.plainLanguageReason ? (
<p className="text-xs leading-5 text-foreground/80">{divergence.plainLanguageReason}</p>
) : null}
<div className="grid gap-2 sm:grid-cols-2">
<BranchFacet
label="Expected · recorded"
@@ -456,8 +544,22 @@ function DivergenceDiagnosis({
sha={divergence.liveHeadSha}
/>
</div>
{divergence.plainLanguageReason ? (
<p className="text-xs leading-5 text-foreground/80">{divergence.plainLanguageReason}</p>
<AheadBehindSummary divergence={divergence} />
{parkedByLabel ? (
<p
data-testid="recovery-parked-by"
className="text-(length:--text-micro) leading-5 text-muted-foreground"
>
Parked on the live branch by{" "}
<code className="font-mono text-foreground/80">{parkedByLabel}</code>
{divergence.parkedByRunId ? (
<>
{" "}
(run <code className="font-mono text-foreground/80">{divergence.parkedByRunId.slice(0, 8)}</code>)
</>
) : null}
.
</p>
) : null}
{divergence.contention ? (
<p
@@ -730,6 +832,97 @@ function RepairWorkspace({
);
}
/**
* The clean-divergence lossless repair — "Restore recorded branch" (PAP-13568 Contract A). The
* worktree is clean and parked on a foreign branch that is itself a real local ref, so a plain
* `git checkout <recorded-branch>` restores it without discarding anything: the parked branch keeps
* its commits on its own ref. Like the quarantine repair this is non-destructive (no reason
* required); the confirm popover simply restates that the parked branch is left untouched and the
* recorded branch will be restored. Invokes the S4 reconcile op in `restore` mode.
*/
function RestoreRecordedBranch({
divergence,
onConfirm,
pending,
}: {
divergence: WorkspaceDivergence;
onConfirm: () => void;
pending: boolean;
}) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
size="sm"
variant="default"
disabled={pending}
data-testid="recovery-action-restore-trigger"
>
{pending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="h-3.5 w-3.5" aria-hidden />
)}
Restore recorded branch
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={6}
aria-labelledby="recovery-restore-title"
className="w-96 max-w-(--sz-calc-4) space-y-3 p-3"
>
<div className="space-y-1">
<div
id="recovery-restore-title"
className="flex items-center gap-1.5 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-emerald-700 dark:text-emerald-300"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden />
Restore recorded branch
</div>
<p className="text-xs leading-5 text-muted-foreground">
This is lossless — no reason required. The worktree is clean, so Paperclip simply checks
out the recorded branch and the task resumes. The parked branch keeps all of its commits
on its own ref; nothing is discarded.
</p>
</div>
<dl
data-testid="recovery-restore-restated"
className="space-y-1.5 rounded-md border border-emerald-400/30 bg-emerald-500/5 px-2.5 py-2 text-(length:--text-micro)"
>
<div className="flex items-center justify-between gap-2">
<dt className="shrink-0 text-muted-foreground">Parked branch</dt>
<dd className="min-w-0 truncate font-mono text-foreground/90">
{divergence.liveBranch ?? "detached"}
<span className="ml-1 font-sans text-muted-foreground">(kept)</span>
</dd>
</div>
<div className="flex items-center justify-between gap-2">
<dt className="shrink-0 text-muted-foreground">Restore to</dt>
<dd className="min-w-0 truncate font-mono text-foreground/90">
{divergence.expectedBranch ?? "recorded branch"}
</dd>
</div>
</dl>
<Button
type="button"
size="sm"
className="w-full"
disabled={pending}
data-testid="recovery-action-restore-confirm"
onClick={() => {
if (pending) return;
onConfirm();
}}
>
{pending ? "Restoring…" : "Restore recorded branch"}
</Button>
</PopoverContent>
</Popover>
);
}
function readWakePolicySummary(action: IssueRecoveryAction): string | null {
const policy = action.wakePolicy;
if (!policy) return null;
@@ -905,6 +1098,8 @@ export function IssueRecoveryActionCard({
onBreakGlassOverride,
onQuarantineRestore,
quarantineRestorePending = false,
onRestoreRecordedBranch,
restorePending = false,
canBreakGlass = false,
reconcilePending = false,
canFalsePositive = false,
@@ -985,6 +1180,16 @@ export function IssueRecoveryActionCard({
cardState !== "resolved" &&
divergence !== null &&
divergence.cleanliness === "dirty";
// The clean-divergence lossless repair — "Restore recorded branch" (PAP-13568 Contract A). Offered
// only for a *clean* divergence (a dirty one quarantines instead). Disabled server-side when the
// recorded branch is contended by another active workspace claim, so we hide it in that case and
// steer to the isolated re-issue like the quarantine repair does.
const showRestoreAction =
onRestoreRecordedBranch !== undefined &&
cardState !== "resolved" &&
divergence !== null &&
divergence.cleanliness === "clean" &&
repairContention === null;
const repairDisabledReason = repairContention
? `Held by ${contentionLabel(repairContention)} — re-issue on an isolated workspace instead.`
: null;
@@ -996,7 +1201,8 @@ export function IssueRecoveryActionCard({
showReissueAction ||
showReconcileForward ||
showBreakGlass ||
showRepairAction;
showRepairAction ||
showRestoreAction;
return (
<section
@@ -1158,11 +1364,18 @@ export function IssueRecoveryActionCard({
</PopoverContent>
</Popover>
) : null}
{showRestoreAction && divergence ? (
<RestoreRecordedBranch
divergence={divergence}
pending={restorePending || reconcilePending}
onConfirm={() => onRestoreRecordedBranch?.()}
/>
) : null}
{showReconcileForward ? (
<Button
type="button"
size="sm"
variant="default"
variant={showRestoreAction ? "outline" : "default"}
disabled={reconcilePending}
data-testid="recovery-action-reconcile-forward"
onClick={() => onReconcileForward?.()}
@@ -214,4 +214,98 @@ describe("RunWorkspaceRecoverySurface", () => {
await flush();
expect(reconcileMock).toHaveBeenCalledWith("ws-1", { mode: "quarantine_restore" });
});
// --- PAP-13568 Phase 4b: clean-divergence "Restore recorded branch" + run-evidence fallback ---
function cleanDivergenceEvidence(overrides: Record<string, unknown> = {}) {
return {
reason: "git_worktree_branch_incoherence",
expectedBranch: "PAP-12915-recorded",
actualBranch: "fix/stable-dry-run-notes-gate",
cleanliness: "clean",
statusEntryCount: 0,
dirtyPathSample: [],
sourceIdentifier: "PAP-13359",
persistedExecutionWorkspaceId: "ws-1",
provenance: {
expectedHeadSha: "aaaa1111bbbb",
actualHeadSha: "cccc2222dddd",
ancestryVerdict: "diverged",
aheadCount: 6,
behindCount: 59,
plainLanguageReason:
"This agent couldn't start because its workspace was left on a different branch.",
parkedByIdentifier: "PAP-13359",
parkedByRunId: "2ef32c67ffff",
},
...overrides,
};
}
it("offers the restore CTA (not the dirty repair) for a clean divergence recovery action", async () => {
const action = buildRecoveryAction();
action.evidence = { workspaceValidation: cleanDivergenceEvidence() };
issueGetMock.mockResolvedValue(buildIssue(action));
boardAccessMock.mockResolvedValue({ source: "local_implicit", companyIds: ["company-1"] });
const node = await renderSurface(buildRun());
expect(node.querySelector("[data-testid='recovery-action-restore-trigger']")).not.toBeNull();
// A clean divergence quarantines nothing, so the dirty repair action is absent.
expect(node.querySelector("[data-testid='recovery-action-repair-trigger']")).toBeNull();
// Ahead/behind + parked-by provenance render.
expect(node.querySelector("[data-testid='recovery-ahead-count']")?.textContent).toContain("6");
expect(node.querySelector("[data-testid='recovery-behind-count']")?.textContent).toContain("59");
expect(node.querySelector("[data-testid='recovery-parked-by']")?.textContent).toContain("PAP-13359");
});
it("wires the restore confirm to reconcile in restore mode against the pinned workspace", async () => {
const action = buildRecoveryAction();
action.evidence = { workspaceValidation: cleanDivergenceEvidence() };
issueGetMock.mockResolvedValue(buildIssue(action));
boardAccessMock.mockResolvedValue({ source: "local_implicit", companyIds: ["company-1"] });
reconcileMock.mockResolvedValue({ id: "ws-1" });
const node = await renderSurface(buildRun());
click(node.querySelector("[data-testid='recovery-action-restore-trigger']"));
click(document.body.querySelector("[data-testid='recovery-action-restore-confirm']"));
await flush();
expect(reconcileMock).toHaveBeenCalledWith("ws-1", { mode: "restore" });
});
it("renders a fallback diagnosis card from run evidence when there is no recovery action", async () => {
// Source issue is blocked / pending-interaction, so no recovery action was promoted (L3), but the
// run stamped branch-incoherence evidence on resultJson.
issueGetMock.mockResolvedValue(buildIssue(null));
boardAccessMock.mockResolvedValue({ source: "local_implicit", companyIds: ["company-1"] });
const node = await renderSurface(
buildRun({ resultJson: { workspaceValidation: cleanDivergenceEvidence() } }),
);
const surface = node.querySelector("[data-testid='run-workspace-recovery-surface']");
expect(surface).not.toBeNull();
expect(surface?.getAttribute("data-fallback")).toBe("true");
expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).not.toBeNull();
expect(node.querySelector("[data-testid='recovery-action-restore-trigger']")).not.toBeNull();
// No persisted action to resolve → the resolve menu is intentionally absent on the fallback.
expect(node.querySelector("[data-testid='recovery-action-resolve-trigger']")).toBeNull();
});
it("wires the fallback restore confirm to reconcile using the evidence workspace id", async () => {
issueGetMock.mockResolvedValue(buildIssue(null));
boardAccessMock.mockResolvedValue({ source: "local_implicit", companyIds: ["company-1"] });
reconcileMock.mockResolvedValue({ id: "ws-1" });
const node = await renderSurface(
buildRun({ resultJson: { workspaceValidation: cleanDivergenceEvidence() } }),
);
click(node.querySelector("[data-testid='recovery-action-restore-trigger']"));
click(document.body.querySelector("[data-testid='recovery-action-restore-confirm']"));
await flush();
expect(reconcileMock).toHaveBeenCalledWith("ws-1", { mode: "restore" });
});
it("renders nothing when a failed run has neither a recovery action nor branch-incoherence evidence", async () => {
issueGetMock.mockResolvedValue(buildIssue(null));
boardAccessMock.mockResolvedValue(undefined);
const node = await renderSurface(
buildRun({ resultJson: { workspaceValidation: { reason: "workspace_not_reusable" } } }),
);
expect(node.querySelector("[data-testid='run-workspace-recovery-surface']")).toBeNull();
});
});
@@ -16,6 +16,11 @@ import {
canBoardManageRuntime,
readRecoveryReconcileWorkspaceId,
} from "../lib/recovery-reconcile";
import {
readReconcileWorkspaceIdFromEvidence,
readRunWorkspaceValidationEvidence,
synthesizeRunWorkspaceValidationAction,
} from "../lib/workspace-validation-evidence";
/** The run errorCode Paperclip stamps when it declines a run over a git workspace it can't validate. */
export const WORKSPACE_VALIDATION_RUN_ERROR_CODE = "workspace_validation_failed";
@@ -75,10 +80,20 @@ export function RunWorkspaceRecoverySurface({ run }: { run: HeartbeatRun }) {
const recoveryAction = issue?.activeRecoveryAction ?? null;
const canManageBoardRuntime = canBoardManageRuntime(run.companyId, boardAccess);
// The run's own workspace-validation evidence (stamped on `resultJson`). Renders the fallback
// diagnosis card when there is no live recovery action to render (PAP-13568 Phase 4b, plan
// point #1: never render nothing on a failed workspace-validation run).
const runEvidence = isWorkspaceValidationFailure
? readRunWorkspaceValidationEvidence(run)
: null;
// Prefer the workspace pinned by the recovery action's evidence (the workspace that actually
// diverged) over the page-level id, which can drift after a re-issue rebinds the issue.
// diverged) over the run evidence, then the page-level id, which can drift after a re-issue
// rebinds the issue.
const reconcileWorkspaceId =
readRecoveryReconcileWorkspaceId(recoveryAction) ?? issue?.executionWorkspaceId ?? null;
readRecoveryReconcileWorkspaceId(recoveryAction) ??
readReconcileWorkspaceIdFromEvidence(runEvidence) ??
issue?.executionWorkspaceId ??
null;
const invalidate = useCallback(() => {
if (issueId) {
@@ -93,26 +108,33 @@ export function RunWorkspaceRecoverySurface({ run }: { run: HeartbeatRun }) {
input:
| { workspaceId: string; mode: "forward" }
| { workspaceId: string; mode: "override"; reason: string }
| { workspaceId: string; mode: "quarantine_restore" },
| { workspaceId: string; mode: "quarantine_restore" }
| { workspaceId: string; mode: "restore" },
) => {
const { workspaceId, ...body } = input;
return executionWorkspacesApi.reconcile(workspaceId, body);
},
onSuccess: (_result, variables) => {
invalidate();
pushToast(
variables.mode === "quarantine_restore"
? {
title: "Workspace repaired",
body: "Dirty changes were quarantined onto a rescue branch and the recorded branch restored; the task will resume.",
tone: "success",
}
: {
title: "Workspace branch reconciled",
body: "The recorded branch now matches the live branch; the task will resume.",
tone: "success",
},
);
if (variables.mode === "quarantine_restore") {
pushToast({
title: "Workspace repaired",
body: "Dirty changes were quarantined onto a rescue branch and the recorded branch restored; the task will resume.",
tone: "success",
});
} else if (variables.mode === "restore") {
pushToast({
title: "Recorded branch restored",
body: "The workspace was checked back out onto its recorded branch (the parked branch keeps its commits); the task will resume.",
tone: "success",
});
} else {
pushToast({
title: "Workspace branch reconciled",
body: "The recorded branch now matches the live branch; the task will resume.",
tone: "success",
});
}
},
onError: (err) => {
pushToast({
@@ -219,6 +241,11 @@ export function RunWorkspaceRecoverySurface({ run }: { run: HeartbeatRun }) {
void reconcile.mutateAsync({ workspaceId: reconcileWorkspaceId, mode: "quarantine_restore" });
}, [reconcile, reconcileWorkspaceId]);
const handleRestoreRecordedBranch = useCallback(() => {
if (!reconcileWorkspaceId) return;
void reconcile.mutateAsync({ workspaceId: reconcileWorkspaceId, mode: "restore" });
}, [reconcile, reconcileWorkspaceId]);
const handleReissue = useCallback(
(request: RecoveryReissueRequest) => {
void reissue.mutateAsync(request);
@@ -250,10 +277,27 @@ export function RunWorkspaceRecoverySurface({ run }: { run: HeartbeatRun }) {
);
if (!isWorkspaceValidationFailure || !issueId) return null;
if (!recoveryAction || recoveryAction.kind !== "workspace_validation") return null;
const hasLiveRecoveryAction =
recoveryAction !== null && recoveryAction.kind === "workspace_validation";
// Fallback path: no live recovery action (e.g. the source issue is blocked / has a pending
// interaction, so no action was promoted — PAP-13568 L3), but the run stamped branch-incoherence
// evidence. Render the same card from the run's own evidence via a presentational synthetic action.
// No `onResolve` is wired — there is no persisted action to resolve; the repair CTAs target the
// evidence's workspace id.
const fallbackAction =
!hasLiveRecoveryAction && runEvidence
? synthesizeRunWorkspaceValidationAction(run, runEvidence, issueId)
: null;
const cardAction = hasLiveRecoveryAction ? recoveryAction : fallbackAction;
if (!cardAction) return null;
const isFallback = !hasLiveRecoveryAction;
return (
<div className="space-y-2" data-testid="run-workspace-recovery-surface">
<div className="space-y-2" data-testid="run-workspace-recovery-surface" data-fallback={isFallback ? "true" : undefined}>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">Workspace recovery</span>
{issue?.identifier ? (
@@ -270,15 +314,20 @@ export function RunWorkspaceRecoverySurface({ run }: { run: HeartbeatRun }) {
) : null}
</div>
<IssueRecoveryActionCard
action={recoveryAction}
action={cardAction}
variant="compact"
onResolve={handleResolve}
// The fallback card has no persisted action to resolve, so its resolve/false-positive menu
// is intentionally omitted; the live-action card keeps it.
onResolve={isFallback ? undefined : handleResolve}
forcedState={isFallback ? "needed" : undefined}
onReissueIsolated={handleReissue}
reissuePending={reissue.isPending}
onReconcileForward={handleReconcileForward}
onBreakGlassOverride={handleBreakGlass}
onQuarantineRestore={handleQuarantineRestore}
quarantineRestorePending={reconcile.isPending}
onRestoreRecordedBranch={handleRestoreRecordedBranch}
restorePending={reconcile.isPending}
reconcilePending={reconcile.isPending}
canBreakGlass={canManageBoardRuntime}
/>
@@ -0,0 +1,97 @@
import type { HeartbeatRun, IssueRecoveryAction } from "@paperclipai/shared";
/**
* PAP-13568 Phase 4b — run-page fallback diagnosis evidence.
*
* A run that Paperclip declines over git workspace validation stamps its structured
* `GitWorktreeBranchIncoherenceEvidence` under `resultJson.workspaceValidation`. The run-page
* recovery surface renders a diagnosis card from *this* evidence directly — no `activeRecoveryAction`
* required — so a failed run never renders nothing (plan point #1). These helpers read that evidence
* off a run and derive the reconcile target, mirroring the equivalent action-based readers in
* `recovery-reconcile.ts`.
*/
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
/**
* Returns the run's `workspaceValidation` evidence record when it is a git-worktree
* branch-incoherence failure (the only shape the diagnosis card renders). Null otherwise.
*/
export function readRunWorkspaceValidationEvidence(
run: Pick<HeartbeatRun, "resultJson">,
): Record<string, unknown> | null {
const resultJson = asRecord(run.resultJson);
if (!resultJson) return null;
const workspaceValidation = asRecord(resultJson.workspaceValidation);
if (!workspaceValidation) return null;
if (workspaceValidation.reason !== "git_worktree_branch_incoherence") return null;
return workspaceValidation;
}
/**
* The execution workspace a reconcile/restore should target, read from the run's evidence. The
* branch-incoherence failure records the diverged workspace under `persistedExecutionWorkspaceId`;
* older/other shapes use `executionWorkspaceId`. Accept either so the reconcile pins the workspace
* that actually diverged rather than a page-level id that may have drifted.
*/
export function readReconcileWorkspaceIdFromEvidence(
evidence: Record<string, unknown> | null,
): string | null {
if (!evidence) return null;
return (
asNonEmptyString(evidence.persistedExecutionWorkspaceId) ??
asNonEmptyString(evidence.executionWorkspaceId)
);
}
/**
* Builds a *presentational-only* synthetic recovery action that carries the run's evidence so the
* shared `IssueRecoveryActionCard` (compact) can render the same diagnosis + repair CTAs from run
* evidence alone. This is never persisted and never resolved (no `onResolve` is wired for it): the
* repair CTAs target the evidence's workspace id, not this synthetic action id. `forcedState` on the
* card drives the tone, so the state-ish fields here are placeholders.
*/
export function synthesizeRunWorkspaceValidationAction(
run: Pick<HeartbeatRun, "id" | "companyId">,
evidence: Record<string, unknown>,
sourceIssueId: string | null,
): IssueRecoveryAction {
return {
id: `run-evidence:${run.id}`,
companyId: run.companyId,
sourceIssueId: sourceIssueId ?? "",
recoveryIssueId: null,
kind: "workspace_validation",
status: "active",
ownerType: "system",
ownerAgentId: null,
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "workspace_validation_failed",
fingerprint: `run-evidence:${run.id}`,
evidence: { workspaceValidation: evidence },
nextAction: "",
wakePolicy: null,
monitorPolicy: null,
attemptCount: 1,
maxAttempts: null,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: new Date(0),
updatedAt: new Date(0),
};
}
+45 -14
View File
@@ -859,6 +859,8 @@ type IssueDetailChatTabProps = {
onBreakGlassOverrideRecoveryAction?: (reason: string) => void;
onQuarantineRestoreRecoveryAction?: () => void;
quarantineRestoreRecoveryActionPending?: boolean;
onRestoreRecordedBranchRecoveryAction?: () => void;
restoreRecoveryActionPending?: boolean;
canBreakGlassRecoveryAction?: boolean;
reconcileRecoveryActionPending?: boolean;
canFalsePositiveRecoveryAction?: boolean;
@@ -950,6 +952,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onBreakGlassOverrideRecoveryAction,
onQuarantineRestoreRecoveryAction,
quarantineRestoreRecoveryActionPending,
onRestoreRecordedBranchRecoveryAction,
restoreRecoveryActionPending,
canBreakGlassRecoveryAction,
reconcileRecoveryActionPending,
canFalsePositiveRecoveryAction,
@@ -1178,6 +1182,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onBreakGlassOverrideRecoveryAction={onBreakGlassOverrideRecoveryAction}
onQuarantineRestoreRecoveryAction={onQuarantineRestoreRecoveryAction}
quarantineRestoreRecoveryActionPending={quarantineRestoreRecoveryActionPending}
onRestoreRecordedBranchRecoveryAction={onRestoreRecordedBranchRecoveryAction}
restoreRecoveryActionPending={restoreRecoveryActionPending}
canBreakGlassRecoveryAction={canBreakGlassRecoveryAction}
reconcileRecoveryActionPending={reconcileRecoveryActionPending}
canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction}
@@ -3743,7 +3749,8 @@ export function IssueDetail() {
input:
| { workspaceId: string; mode: "forward" }
| { workspaceId: string; mode: "override"; reason: string }
| { workspaceId: string; mode: "quarantine_restore" },
| { workspaceId: string; mode: "quarantine_restore" }
| { workspaceId: string; mode: "restore" },
) => {
const { workspaceId, ...body } = input;
return executionWorkspacesApi.reconcile(workspaceId, body);
@@ -3753,19 +3760,25 @@ export function IssueDetail() {
// clears the active recovery action, so the card must re-fetch to stop showing stale actions.
invalidateIssueDetail();
invalidateIssueCollections();
pushToast(
variables.mode === "quarantine_restore"
? {
title: "Workspace repaired",
body: "Dirty changes were quarantined onto a rescue branch and the recorded branch restored; the task will resume.",
tone: "success",
}
: {
title: "Workspace branch reconciled",
body: "The recorded branch now matches the live branch; the task will resume.",
tone: "success",
},
);
if (variables.mode === "quarantine_restore") {
pushToast({
title: "Workspace repaired",
body: "Dirty changes were quarantined onto a rescue branch and the recorded branch restored; the task will resume.",
tone: "success",
});
} else if (variables.mode === "restore") {
pushToast({
title: "Recorded branch restored",
body: "The workspace was checked back out onto its recorded branch (the parked branch keeps its commits); the task will resume.",
tone: "success",
});
} else {
pushToast({
title: "Workspace branch reconciled",
body: "The recorded branch now matches the live branch; the task will resume.",
tone: "success",
});
}
},
onError: (err) => {
pushToast({
@@ -3831,6 +3844,22 @@ export function IssueDetail() {
mode: "quarantine_restore",
});
}, [reconcileExecutionWorkspaceId, reconcileRecoveryAction.mutateAsync, pushToast]);
// Restore action (workspace_validation, clean divergence — PAP-13568 Contract A): check out the
// recorded branch. Lossless — the parked branch keeps its commits on its own ref, no reason needed.
const handleRestoreRecordedBranchRecoveryAction = useCallback(() => {
if (!reconcileExecutionWorkspaceId) {
pushToast({
title: "Restore failed",
body: "This task has no execution workspace to restore.",
tone: "error",
});
return;
}
void reconcileRecoveryAction.mutateAsync({
workspaceId: reconcileExecutionWorkspaceId,
mode: "restore",
});
}, [reconcileExecutionWorkspaceId, reconcileRecoveryAction.mutateAsync, pushToast]);
const treePreviewAffectedIssues = useMemo(
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
@@ -4730,6 +4759,8 @@ export function IssueDetail() {
onBreakGlassOverrideRecoveryAction={handleBreakGlassOverrideRecoveryAction}
onQuarantineRestoreRecoveryAction={handleQuarantineRestoreRecoveryAction}
quarantineRestoreRecoveryActionPending={reconcileRecoveryAction.isPending}
onRestoreRecordedBranchRecoveryAction={handleRestoreRecordedBranchRecoveryAction}
restoreRecoveryActionPending={reconcileRecoveryAction.isPending}
canBreakGlassRecoveryAction={canManageBoardRuntime}
reconcileRecoveryActionPending={reconcileRecoveryAction.isPending}
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
@@ -414,6 +414,157 @@ function InboxRowPanel() {
);
}
// --- PAP-13568 Phase 4b: workspace-validation diagnosis card (restore + run-evidence fallback) ---
function buildBranchIncoherenceAction(
workspaceValidation: Record<string, unknown>,
): IssueRecoveryAction {
return buildAction({
kind: "workspace_validation",
cause: "workspace_validation_failed",
fingerprint: "fp-branch-incoherence",
nextAction: "Restore the recorded branch, then the task resumes.",
evidence: {
workspaceValidation: {
reason: "git_worktree_branch_incoherence",
persistedExecutionWorkspaceId: "00000000-0000-0000-0000-0000000000ws",
...workspaceValidation,
},
},
});
}
const CLEAN_DIVERGED = buildBranchIncoherenceAction({
expectedBranch: "PAP-12915-github-release-action-verify-stable",
actualBranch: "fix/stable-dry-run-notes-gate",
cleanliness: "clean",
statusEntryCount: 0,
sourceIdentifier: "PAP-13359",
provenance: {
expectedHeadSha: "390627b4aaaa",
actualHeadSha: "9a1d4b79bbbb",
ancestryVerdict: "diverged",
aheadCount: 6,
behindCount: 59,
parkedByIdentifier: "PAP-13359",
parkedByRunId: "2ef32c67ffff",
plainLanguageReason:
"This agent couldn't start because its workspace was left on a different branch (fix/stable-dry-run-notes-gate) by PAP-13359's run on Jul 10. The recorded branch keeps its commits — restoring it is lossless.",
},
});
const CLEAN_ANCESTOR = buildBranchIncoherenceAction({
expectedBranch: "PAP-12915-recorded",
actualBranch: "PAP-12915-recorded-follow-up",
cleanliness: "clean",
statusEntryCount: 0,
sourceIdentifier: "PAP-12915",
provenance: {
expectedHeadSha: "390627b4aaaa",
actualHeadSha: "aa11bb22cc33",
ancestryVerdict: "ancestor",
aheadCount: 3,
behindCount: 0,
plainLanguageReason:
"The live branch is a fast-forward descendant of the recorded branch — Paperclip can reconcile forward or restore the recorded branch.",
},
});
const CONTENDED_CLEAN = buildBranchIncoherenceAction({
expectedBranch: "PAP-12915-recorded",
actualBranch: "fix/stable-dry-run-notes-gate",
cleanliness: "clean",
statusEntryCount: 0,
sourceIdentifier: "PAP-13359",
contention: {
claimedByWorkspaceId: "00000000-0000-0000-0000-0000000000w2",
claimedByIssueId: "00000000-0000-0000-0000-0000000000i2",
claimedByIssueIdentifier: "PAP-13360",
activeRun: {
id: "00000000-0000-0000-0000-0000000000r2",
status: "running",
issueId: "00000000-0000-0000-0000-0000000000i2",
issueIdentifier: "PAP-13360",
},
},
provenance: {
expectedHeadSha: "390627b4aaaa",
actualHeadSha: "9a1d4b79bbbb",
ancestryVerdict: "diverged",
plainLanguageReason:
"Another active workspace is holding the live branch, so the recorded branch can't be safely restored here — re-issue on an isolated workspace instead.",
},
});
const DIRTY_DIVERGED = buildBranchIncoherenceAction({
expectedBranch: "PAP-12915-recorded",
actualBranch: "PAP-12915-wip",
cleanliness: "dirty",
statusEntryCount: 4,
dirtyPathSample: ["server/src/app.ts", "ui/src/index.css"],
sourceIdentifier: "PAP-12915",
provenance: {
expectedHeadSha: "390627b4aaaa",
actualHeadSha: "cd77ee88ff00",
ancestryVerdict: "diverged",
plainLanguageReason:
"The worktree has uncommitted changes on a diverged branch — Paperclip quarantines them onto a rescue branch before restoring the recorded branch.",
},
});
function WorkspaceValidationCard({ caption, action, variant }: {
caption: string;
action: IssueRecoveryAction;
variant?: "full" | "compact";
}) {
return (
<section className="space-y-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{caption}
</div>
<IssueRecoveryActionCard
action={action}
agentMap={storybookAgentMap}
variant={variant}
onReconcileForward={() => {}}
onBreakGlassOverride={() => {}}
onQuarantineRestore={() => {}}
onRestoreRecordedBranch={() => {}}
onReissueIsolated={() => {}}
canBreakGlass
/>
</section>
);
}
function WorkspaceValidationPanel() {
return (
<div className="grid gap-5 lg:grid-cols-1">
<WorkspaceValidationCard
caption="Clean · diverged — the root-cause case (restore recorded branch, lossless)"
action={CLEAN_DIVERGED}
/>
<WorkspaceValidationCard
caption="Clean · forward-only ancestor (restore or reconcile forward)"
action={CLEAN_ANCESTOR}
/>
<WorkspaceValidationCard
caption="Clean · contended (restore hidden — re-issue recommended)"
action={CONTENDED_CLEAN}
/>
<WorkspaceValidationCard
caption="Dirty · diverged (quarantine changes & restore branch)"
action={DIRTY_DIVERGED}
/>
<WorkspaceValidationCard
caption="Run-page fallback (compact variant, rendered from run evidence)"
action={CLEAN_DIVERGED}
variant="compact"
/>
</div>
);
}
const meta = {
title: "Paperclip/Source Issue Recovery",
component: AllStatesPanel,
@@ -435,6 +586,17 @@ export const RecoveryActionCardStates: Story = {
),
};
export const WorkspaceValidationDiagnosisStates: Story = {
render: () => (
<StoryFrame
title="Workspace-validation diagnosis card (PAP-13568 Phase 4b)"
description="Diagnosis card rendered from workspace-validation evidence: plain-language reason first, expected-vs-found branches with ahead/behind, parked-by provenance, and a lossless 'Restore recorded branch' CTA (demoted break-glass override behind a reason prompt). The compact variant is the run-page fallback rendered from the run's own evidence."
>
<WorkspaceValidationPanel />
</StoryFrame>
),
};
export const InboxRowChips: Story = {
render: () => (
<StoryFrame