Gate stale-run watchdog decisions by board access (#4446)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The run ledger surfaces stale-run watchdog evaluation issues and
recovery actions
> - Viewer-level board users should be able to inspect status without
getting controls that the server will reject
> - The UI also needs enough board-access context to know when to hide
those decision actions
> - This pull request exposes board memberships in the current board
access snapshot and gates watchdog action controls for known viewer
contexts
> - The benefit is clearer least-privilege UI behavior around recovery
controls

## What Changed

- Included memberships in `/api/cli-auth/me` so the board UI can
distinguish active viewer memberships from operator/admin access.
- Added the stale-run evaluation issue assignee to output silence
summaries.
- Hid stale-run watchdog decision buttons for known non-owner viewer
contexts.
- Surfaced watchdog decision failures through toast and inline error
text.
- Threaded `companyId` through the issue activity run ledger so access
checks are company-scoped.
- Added IssueRunLedger coverage for non-owner viewers.

## Verification

- `pnpm exec vitest run --project @paperclipai/ui
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`

## Risks

- Medium-low risk. This is a UI gating change backed by existing server
authorization.
- Local implicit and instance-admin board contexts continue to show
watchdog decision controls.
- No migrations.

> 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 with
shell/GitHub/Paperclip API access. Context window was not reported by
the runtime.

## 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>
This commit is contained in:
Dotta
2026-04-24 19:25:23 -05:00
committed by GitHub
parent 6916e30f8e
commit 73fbdf36db
7 changed files with 97 additions and 2 deletions
+1
View File
@@ -77,6 +77,7 @@ export interface HeartbeatRunOutputSilence {
snoozedUntil: Date | string | null;
evaluationIssueId: string | null;
evaluationIssueIdentifier: string | null;
evaluationIssueAssigneeAgentId: string | null;
}
export interface AgentWakeupSkipped {
+1
View File
@@ -2617,6 +2617,7 @@ export function accessRoutes(
userId: req.actor.userId,
isInstanceAdmin: accessSnapshot.isInstanceAdmin,
companyIds: accessSnapshot.companyIds,
memberships: accessSnapshot.memberships,
source: req.actor.source ?? "none",
keyId: req.actor.source === "board_key" ? req.actor.keyId ?? null : null,
});
+2
View File
@@ -82,6 +82,7 @@ export type RunOutputSilenceSummary = {
snoozedUntil: Date | null;
evaluationIssueId: string | null;
evaluationIssueIdentifier: string | null;
evaluationIssueAssigneeAgentId: string | null;
};
function readNonEmptyString(value: unknown): string | null {
@@ -590,6 +591,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
snoozedUntil: quietUntilDecision?.snoozedUntil ?? null,
evaluationIssueId: evaluation?.id ?? null,
evaluationIssueIdentifier: evaluation?.identifier ?? null,
evaluationIssueAssigneeAgentId: evaluation?.assigneeAgentId ?? null,
};
}
+5
View File
@@ -238,6 +238,11 @@ export type CurrentBoardAccess = {
userId: string;
isInstanceAdmin: boolean;
companyIds: string[];
memberships?: Array<{
companyId: string;
membershipRole: HumanCompanyRole | "member" | null;
status: "pending" | "active" | "suspended" | "archived";
}>;
source: string;
keyId: string | null;
};
+21
View File
@@ -124,6 +124,7 @@ function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunF
snoozedUntil: null,
evaluationIssueId: "issue-eval-1",
evaluationIssueIdentifier: "PAP-404",
evaluationIssueAssigneeAgentId: "agent-owner",
},
...overrides,
};
@@ -139,6 +140,8 @@ function renderLedger(props: Partial<ComponentProps<typeof IssueRunLedgerContent
childIssues={props.childIssues ?? []}
agentMap={props.agentMap ?? new Map([["agent-1", { name: "CodexCoder" }]])}
pendingWatchdogDecision={props.pendingWatchdogDecision}
canRecordWatchdogDecisions={props.canRecordWatchdogDecisions}
watchdogDecisionError={props.watchdogDecisionError}
onWatchdogDecision={props.onWatchdogDecision}
/>,
);
@@ -366,4 +369,22 @@ describe("IssueRunLedger", () => {
evaluationIssueId: "issue-eval-1",
});
});
it("hides watchdog decision actions for known non-owner viewers", () => {
const onWatchdogDecision = vi.fn();
renderLedger({
runs: [createRun({ runId: "run-live-1", status: "running", finishedAt: null })],
activeRun: createActiveRun(),
canRecordWatchdogDecisions: false,
onWatchdogDecision,
});
expect(container.textContent).toContain("Stale-run watchdog alert");
expect(container.textContent).toContain("PAP-404");
expect(container.textContent).not.toContain("Continue monitoring");
expect(container.textContent).not.toContain("Snooze 1h");
expect(container.textContent).not.toContain("Mark false positive");
expect(container.querySelectorAll("button")).toHaveLength(0);
expect(onWatchdogDecision).not.toHaveBeenCalled();
});
});
+63 -2
View File
@@ -1,14 +1,17 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import type { Issue, Agent } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@/lib/router";
import { accessApi, type CurrentBoardAccess } from "../api/access";
import { activityApi, type RunForIssue, type RunLivenessState } from "../api/activity";
import { ApiError } from "../api/client";
import {
heartbeatsApi,
type ActiveRunForIssue,
type LiveRunForIssue,
type WatchdogDecisionInput,
} from "../api/heartbeats";
import { useToastActions } from "../context/ToastContext";
import { cn, relativeTime } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
@@ -16,6 +19,7 @@ import { describeRunRetryState } from "../lib/runRetryState";
type IssueRunLedgerProps = {
issueId: string;
companyId: string;
issueStatus: Issue["status"];
childIssues: Issue[];
agentMap: ReadonlyMap<string, Agent>;
@@ -30,6 +34,8 @@ type IssueRunLedgerContentProps = {
childIssues: Issue[];
agentMap: ReadonlyMap<string, Pick<Agent, "name">>;
pendingWatchdogDecision?: WatchdogDecisionInput["decision"] | null;
canRecordWatchdogDecisions?: boolean;
watchdogDecisionError?: string | null;
onWatchdogDecision?: (input: WatchdogDecisionInput) => void;
};
@@ -309,14 +315,45 @@ function formatSilenceAge(ms: number | null | undefined) {
return `${hours}h ${minutes}m`;
}
function canBoardRecordWatchdogDecision(
companyId: string,
boardAccess: CurrentBoardAccess | undefined,
) {
if (!boardAccess) return false;
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
const membership = boardAccess.memberships?.find(
(item) => item.companyId === companyId && item.status === "active",
);
if (!membership) return boardAccess.companyIds.includes(companyId) && !boardAccess.memberships;
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
}
function watchdogDecisionErrorMessage(error: unknown) {
if (error instanceof ApiError && error.status === 403) {
return "Only the board or the assigned recovery owner can record watchdog decisions";
}
return error instanceof Error && error.message.trim().length > 0
? error.message
: "Paperclip could not record the watchdog decision.";
}
export function IssueRunLedger({
issueId,
companyId,
issueStatus,
childIssues,
agentMap,
hasLiveRuns,
}: IssueRunLedgerProps) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [watchdogDecisionError, setWatchdogDecisionError] = useState<string | null>(null);
const { data: boardAccess } = useQuery({
queryKey: queryKeys.access.currentBoardAccess,
queryFn: () => accessApi.getCurrentBoardAccess(),
retry: false,
});
const { data: runs } = useQuery({
queryKey: queryKeys.issues.runs(issueId),
queryFn: () => activityApi.runsForIssue(issueId),
@@ -339,10 +376,25 @@ export function IssueRunLedger({
});
const watchdogDecision = useMutation({
mutationFn: (input: WatchdogDecisionInput) => heartbeatsApi.recordWatchdogDecision(input),
onMutate: () => {
setWatchdogDecisionError(null);
},
onSuccess: () => {
setWatchdogDecisionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) });
},
onError: (error) => {
const message = watchdogDecisionErrorMessage(error);
const dedupeSuffix = error instanceof ApiError ? String(error.status) : "error";
setWatchdogDecisionError(message);
pushToast({
title: "Watchdog decision not recorded",
body: message,
tone: "error",
dedupeKey: `watchdog-decision:${issueId}:${dedupeSuffix}`,
});
},
});
return (
@@ -354,6 +406,8 @@ export function IssueRunLedger({
childIssues={childIssues}
agentMap={agentMap}
pendingWatchdogDecision={watchdogDecision.variables?.decision ?? null}
canRecordWatchdogDecisions={canBoardRecordWatchdogDecision(companyId, boardAccess)}
watchdogDecisionError={watchdogDecisionError}
onWatchdogDecision={(input) => watchdogDecision.mutate(input)}
/>
);
@@ -367,6 +421,8 @@ export function IssueRunLedgerContent({
childIssues,
agentMap,
pendingWatchdogDecision,
canRecordWatchdogDecisions = true,
watchdogDecisionError,
onWatchdogDecision,
}: IssueRunLedgerContentProps) {
const ledgerRuns = useMemo(() => mergeRuns(runs, liveRuns, activeRun), [activeRun, liveRuns, runs]);
@@ -468,7 +524,7 @@ export function IssueRunLedgerContent({
</>
) : null}
</p>
{onWatchdogDecision ? (
{onWatchdogDecision && canRecordWatchdogDecisions ? (
<div className="mt-2 flex flex-wrap gap-1.5">
<button
type="button"
@@ -514,6 +570,11 @@ export function IssueRunLedgerContent({
</button>
</div>
) : null}
{watchdogDecisionError ? (
<p className="mt-2 rounded-md border border-red-500/30 bg-red-500/10 px-2 py-1 text-[11px] text-red-900 dark:text-red-200">
{watchdogDecisionError}
</p>
) : null}
</div>
) : null}
+4
View File
@@ -837,6 +837,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
type IssueDetailActivityTabProps = {
issueId: string;
companyId: string;
issueStatus: Issue["status"];
childIssues: Issue[];
agentMap: Map<string, Agent>;
@@ -850,6 +851,7 @@ type IssueDetailActivityTabProps = {
function IssueDetailActivityTab({
issueId,
companyId,
issueStatus,
childIssues,
agentMap,
@@ -941,6 +943,7 @@ function IssueDetailActivityTab({
<div className="mb-3">
<IssueRunLedger
issueId={issueId}
companyId={companyId}
issueStatus={issueStatus}
childIssues={childIssues}
agentMap={agentMap}
@@ -3410,6 +3413,7 @@ export function IssueDetail() {
{detailTab === "activity" ? (
<IssueDetailActivityTab
issueId={issue.id}
companyId={issue.companyId}
issueStatus={issue.status}
childIssues={childIssues}
agentMap={agentMap}