From 73fbdf36db15f7e1ef5eea0c9834804b0b54fd43 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:25:23 -0500 Subject: [PATCH] Gate stale-run watchdog decisions by board access (#4446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- packages/shared/src/types/heartbeat.ts | 1 + server/src/routes/access.ts | 1 + server/src/services/recovery/service.ts | 2 + ui/src/api/access.ts | 5 ++ ui/src/components/IssueRunLedger.test.tsx | 21 ++++++++ ui/src/components/IssueRunLedger.tsx | 65 ++++++++++++++++++++++- ui/src/pages/IssueDetail.tsx | 4 ++ 7 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index 5aee87af..973d5d6c 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -77,6 +77,7 @@ export interface HeartbeatRunOutputSilence { snoozedUntil: Date | string | null; evaluationIssueId: string | null; evaluationIssueIdentifier: string | null; + evaluationIssueAssigneeAgentId: string | null; } export interface AgentWakeupSkipped { diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 3df49fd6..44acab90 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -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, }); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index f2c2f58d..f20dd059 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -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, }; } diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index c8f83134..b5b68946 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -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; }; diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index adefcf4f..7ab4bf1c 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -124,6 +124,7 @@ function createActiveRun(overrides: Partial = {}): ActiveRunF snoozedUntil: null, evaluationIssueId: "issue-eval-1", evaluationIssueIdentifier: "PAP-404", + evaluationIssueAssigneeAgentId: "agent-owner", }, ...overrides, }; @@ -139,6 +140,8 @@ function renderLedger(props: Partial, ); @@ -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(); + }); }); diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index 54ceaf0a..45f9bc26 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -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; @@ -30,6 +34,8 @@ type IssueRunLedgerContentProps = { childIssues: Issue[]; agentMap: ReadonlyMap>; 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(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}

- {onWatchdogDecision ? ( + {onWatchdogDecision && canRecordWatchdogDecisions ? (
) : null} + {watchdogDecisionError ? ( +

+ {watchdogDecisionError} +

+ ) : null} ) : null} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index f35fa22b..319cb7b7 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -837,6 +837,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ type IssueDetailActivityTabProps = { issueId: string; + companyId: string; issueStatus: Issue["status"]; childIssues: Issue[]; agentMap: Map; @@ -850,6 +851,7 @@ type IssueDetailActivityTabProps = { function IssueDetailActivityTab({ issueId, + companyId, issueStatus, childIssues, agentMap, @@ -941,6 +943,7 @@ function IssueDetailActivityTab({