mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
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:
1 parent
e4e12bfb89
commit
8c082f84e2
9 files changed
+819
-46
No files matched your search
@@ -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),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user