forked from farhoodlabs/paperclip
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:
@@ -77,6 +77,7 @@ export interface HeartbeatRunOutputSilence {
|
||||
snoozedUntil: Date | string | null;
|
||||
evaluationIssueId: string | null;
|
||||
evaluationIssueIdentifier: string | null;
|
||||
evaluationIssueAssigneeAgentId: string | null;
|
||||
}
|
||||
|
||||
export interface AgentWakeupSkipped {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user