Files
paperclip/ui/src/components/IssueBlockedNotice.tsx
T
Dotta 38c185fb8b [codex] Add agent permissions and controls plan (#6386)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies by keeping
task ownership, approvals, and operator control inside one control
plane.
> - Agent permissions and plugin-hosted company settings sit on the
boundary between autonomy and governance.
> - V1 needs scoped task assignment rules, plugin extension points, and
clearer company access surfaces without weakening company boundaries.
> - The branch builds the core authorization service, plugin SDK/host
APIs, and UI simplifications needed to support those controls.
> - Paperclip EE plugin surfaces were intentionally moved out of this
core PR per review direction, so this PR now carries only the public
core/plugin infrastructure work.
> - The latest updates preserve the PAP-9937 branch changes that belong
in this PR, remove the `design/` artifacts, and exclude the experimental
`plugin-briefs` package.
> - Greptile feedback was applied through the authorization/audit paths
and the final cleanup commit was re-reviewed at 5/5 with no unresolved
Greptile threads.
> - The benefit is safer assignment control with extension hooks for
richer permission products while preserving simple defaults for normal
operators.

## What Changed

- Added scoped task-assignment authorization decisions and routed
issue/agent assignment mutations through the authorization service.
- Added plugin SDK and host APIs for company settings slots,
authorization policy/grant management, assignment previews, and bridge
invocation scope propagation.
- Simplified core company access UI and moved advanced controls behind
plugin-provided settings surfaces.
- Added retry-now affordances for blocked issue next-step notices.
- Added protected-assignment enforcement for persisted
agent/project/issue policies, including explicit-grant fallback
behavior.
- Added incremental principal-access compatibility backfill for active
agent memberships and role-default human permission grants.
- Added the Markdown code block wrap action fix from the latest branch
changes.
- Removed `design/` artifacts from the PR and removed
`packages/plugins/plugin-briefs` from the final diff.
- Addressed Greptile feedback for plugin actor sanitization, legacy
membership handling, audit pagination, unknown grant-scope metadata, and
startup test mocks.

## Verification

- `pnpm exec vitest run server/src/__tests__/access-service.test.ts
server/src/__tests__/company-portability.test.ts` -> 2 files passed, 54
tests passed.
- `pnpm exec vitest run
server/src/__tests__/server-startup-feedback-export.test.ts
server/src/__tests__/access-service.test.ts
server/src/__tests__/company-portability.test.ts` -> 3 files passed, 62
tests passed.
- `pnpm exec vitest run
server/src/__tests__/authorization-service.test.ts
server/src/__tests__/plugin-access-authorization-host-services.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts` -> 3 files
passed, 28 tests passed.
- `pnpm --filter @paperclipai/server typecheck` -> passed.
- `git diff --check` -> passed.
- `node ./scripts/check-docker-deps-stage.mjs` -> passed.
- `CI=true pnpm install --frozen-lockfile --ignore-scripts` -> passed
with no lockfile update.
- `pnpm exec vitest run
ui/src/components/MarkdownBody.interaction.test.tsx` -> 1 test passed.
- `git ls-files design packages/plugins/plugin-briefs | wc -l` -> 0.
- GitHub CI on `40cd83b53` -> all checks passed, merge state `CLEAN`.
- Greptile on `40cd83b53` -> 5/5, 102 files reviewed, 0
comments/annotations added, 0 unresolved review threads.
- Confirmed the PR diff contains no `design/`,
`packages/plugins/plugin-briefs`, `pnpm-lock.yaml`, or
`.github/workflows` changes.

## Risks

- Medium: task assignment authorization paths are behaviorally stricter
for protected/private policy data, so existing plugin-authored policies
may block assignment until explicit grants or approval flows are
configured.
- Medium: plugin-host authorization APIs expand the surface area
available to trusted plugins and need careful review for company
scoping.
- Low: startup now performs a principal-access compatibility backfill,
but the migration and runtime backfill use conflict-tolerant inserts.

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

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled workflow with shell,
git, and GitHub CLI access.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-22 08:12:52 -05:00

305 lines
14 KiB
TypeScript

import type {
IssueBlockerAttention,
IssueRecoveryAction,
IssueRelationIssueSummary,
IssueScheduledRetry,
SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { AlertTriangle, CheckCircle2, Flag, Loader2, RotateCcw } from "lucide-react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { formatMonitorOffset } from "../lib/issue-monitor";
import { useRetryNowMutation } from "../hooks/useRetryNowMutation";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { RetryErrorBand } from "./IssueScheduledRetryCard";
import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
import {
deriveActiveRecoveryDisplayState,
RECOVERY_CHIP_DEFAULT_TONE,
} from "../lib/recovery-display";
function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) {
const state = deriveActiveRecoveryDisplayState(action);
if (!state) return null;
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
const Icon = tone.icon;
return (
<span
data-testid="issue-blocked-notice-recovery-indicator"
data-recovery-state={state}
role="status"
aria-label={tone.label}
title={`${tone.label} — open the source issue to act.`}
className={`inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${tone.className}`}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{tone.label}
</span>
);
}
function SuccessfulRunRetryNowControl({
issueId,
scheduledRetry,
}: {
issueId: string;
scheduledRetry: IssueScheduledRetry;
}) {
const retryNow = useRetryNowMutation(issueId);
const dueAtIso = scheduledRetry.scheduledRetryAt
? new Date(scheduledRetry.scheduledRetryAt).toISOString()
: null;
const relative = dueAtIso ? formatMonitorOffset(dueAtIso) : null;
const scheduleLabel = relative === "now"
? "due now"
: relative
? `scheduled ${relative}`
: "scheduled";
const success = retryNow.isSuccess
&& (retryNow.data?.outcome === "promoted" || retryNow.data?.outcome === "already_promoted");
return (
<div className="mt-2 rounded-md border border-amber-300/70 bg-background/80 p-2 dark:border-amber-500/40 dark:bg-background/40">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 text-xs leading-5 text-amber-900 dark:text-amber-100">
Corrective wake {scheduleLabel}. Retry now starts the same recovery path immediately.
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 border-amber-300/80 bg-background/80 text-amber-950 shadow-none hover:bg-amber-100 dark:border-amber-500/50 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
onClick={() => retryNow.mutate()}
disabled={retryNow.isPending || success}
data-testid="issue-next-step-retry-now"
>
{retryNow.isPending ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
Retrying...
</span>
) : success ? (
<span className="inline-flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
{retryNow.data?.outcome === "already_promoted" ? "Already promoted" : "Promoted"}
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
Retry now
</span>
)}
</Button>
</div>
<RetryErrorBand
error={retryNow.lastError}
className="mt-2 border-amber-300/70 bg-amber-100/70 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-100"
onRetry={() => {
retryNow.reset();
retryNow.mutate();
}}
/>
</div>
);
}
export function IssueBlockedNotice({
issueId,
issueStatus,
blockers,
blockerAttention,
successfulRunHandoff,
scheduledRetry,
agentName,
}: {
issueId?: string | null;
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
scheduledRetry?: IssueScheduledRetry | null;
agentName?: string | null;
}) {
if (issueStatus === "done" || issueStatus === "cancelled") return null;
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
const successfulRunRetryNow = showSuccessfulRunHandoff
&& issueId
&& scheduledRetry?.status === "scheduled_retry"
? { issueId, scheduledRetry }
: null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
.flatMap((blocker) => blocker.terminalBlockers ?? [])
.filter((blocker, index, all) => all.findIndex((candidate) => candidate.id === blocker.id) === index);
const isStalled = blockerAttention?.state === "stalled";
const parkedBlockers = (() => {
const seen = new Set<string>();
const collected: IssueRelationIssueSummary[] = [];
const sources: IssueRelationIssueSummary[] = [...blockers];
for (const blocker of blockers) {
for (const terminal of blocker.terminalBlockers ?? []) {
sources.push(terminal);
}
}
for (const blocker of sources) {
if (!isAssignedBacklogBlocker(blocker)) continue;
if (seen.has(blocker.id)) continue;
seen.add(blocker.id);
collected.push(blocker);
}
return collected;
})();
const showParkedRow = parkedBlockers.length > 0;
const stalledLeafIdentifier =
blockerAttention?.sampleStalledBlockerIdentifier ?? blockerAttention?.sampleBlockerIdentifier ?? null;
const stalledLeafBlockers = (() => {
const candidates: IssueRelationIssueSummary[] = [];
for (const blocker of [...blockers, ...terminalBlockers]) {
if (blocker.status !== "in_review") continue;
if (candidates.some((existing) => existing.id === blocker.id)) continue;
candidates.push(blocker);
}
if (stalledLeafIdentifier) {
const preferred = candidates.find(
(blocker) => (blocker.identifier ?? blocker.id) === stalledLeafIdentifier,
);
if (preferred) {
return [preferred, ...candidates.filter((blocker) => blocker.id !== preferred.id)];
}
}
return candidates;
})();
const showStalledRow = isStalled && stalledLeafBlockers.length > 0;
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
const issuePathId = blocker.identifier ?? blocker.id;
const recoveryAction = blocker.activeRecoveryAction ?? null;
return (
<IssueLinkQuicklook
key={blocker.id}
issuePathId={issuePathId}
to={createIssueDetailPath(issuePathId)}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
>
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
{blocker.title}
</span>
{recoveryAction ? <BlockerRecoveryIndicator action={recoveryAction} /> : null}
</IssueLinkQuicklook>
);
};
return (
<div
data-blocker-attention-state={blockerAttention?.state}
data-successful-run-handoff={showSuccessfulRunHandoff ? "required" : undefined}
className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 space-y-1.5">
{showSuccessfulRunHandoff ? (
<>
<p className="font-medium leading-5">This issue still needs a next step.</p>
<p className="leading-5">
A run finished successfully, but this issue is still open in{" "}
<code className="rounded bg-amber-100 px-1 py-0.5 text-[12px] dark:bg-amber-400/15">
in_progress
</code>{" "}
with no clear owner for the next action.
</p>
<ul className="list-disc space-y-1 pl-5 text-xs leading-5 text-amber-900 dark:text-amber-100">
<li>Mark it done or cancelled.</li>
<li>Send it for review or ask for input.</li>
<li>Mark it blocked with a blocker owner.</li>
<li>Delegate follow-up work or queue a continuation.</li>
</ul>
<div className="flex flex-wrap gap-1.5 text-xs">
{successfulRunHandoff.sourceRunId && successfulRunHandoff.assigneeAgentId ? (
<Link
to={`/agents/${successfulRunHandoff.assigneeAgentId}/runs/${successfulRunHandoff.sourceRunId}`}
className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
>
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
</Link>
) : successfulRunHandoff.sourceRunId ? (
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
</span>
) : null}
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 text-amber-900 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
Corrective wake queued for {agentName ?? "the assignee"}
</span>
</div>
{successfulRunHandoff.detectedProgressSummary ? (
<p className="text-xs leading-5 text-amber-800 dark:text-amber-200">
Detected progress: {successfulRunHandoff.detectedProgressSummary}
</p>
) : null}
{successfulRunRetryNow ? (
<SuccessfulRunRetryNowControl
issueId={successfulRunRetryNow.issueId}
scheduledRetry={successfulRunRetryNow.scheduledRetry}
/>
) : null}
</>
) : null}
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (
<div className="border-t border-amber-300/60 pt-1.5 dark:border-amber-500/30" />
) : null}
{blockers.length > 0 || issueStatus === "blocked" ? (
<>
<p className="leading-5">
{blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
</div>
) : null}
{showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Stalled in review
</span>
{stalledLeafBlockers.map(renderBlockerChip)}
</div>
) : terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
) : null}
{showParkedRow ? (
<div
data-testid="issue-blocked-notice-parked-row"
className="flex flex-wrap items-center gap-1.5 pt-0.5"
>
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-800 dark:text-amber-200">
<Flag className="h-3 w-3" aria-hidden />
Blocked by parked work
</span>
{parkedBlockers.map(renderBlockerChip)}
</div>
) : null}
</>
) : null}
</div>
</div>
</div>
);
}