diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 60105e3361..d5104e2162 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -978,6 +978,14 @@ Core authorization follows these rules: - New qualifying issue activity may invalidate an archive so the item resurfaces; archival is not a substitute for resolving or closing work. - Viewing an issue may update its per-user read receipt, but read receipts alone do not enroll the issue in Mine. Mine participation begins with a user-authored comment, issue creation/assignment, or another audited user mutation; explicit product actions such as manually running a routine may record an audited inbox touch. +Failed-run rows in Mine and the inbox badge use the run's `responsibleUserId`. +Another user's run does not appear there, even for a shared agent. Select the +latest run per agent before checking ownership so older failures do not resurface. +Unattributed runs appear only for `local-board` in Mine; an unresolved viewer +identity shows no failed runs. All retains company-wide failed-run visibility. +Company health alerts do not contribute to the personal inbox badge. +Run list responses, including summaries, retain `responsibleUserId`. + Ownership split: - **Core / Free:** permission key and scoped-grant enforcement; responsible-user resolution; default-open, disabled, and allowlist policy modes; archive/unarchive APIs; per-user archive persistence; resurfacing behavior; activity audit records; and stable denial codes. diff --git a/doc/SPEC.md b/doc/SPEC.md index 8840bff89e..4a9a990a84 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -282,6 +282,7 @@ Experimental Agent Chat presents one persistent task per person and agent as a s ### Implications - An agent's "inbox" is: tasks assigned to them + comments on tasks they're involved in +- A human's Mine inbox and its badge include failed runs attributed to that human, not another user's runs. All retains company-wide failure visibility. Historical unattributed runs remain in the local single-user board's Mine view; see `SPEC-implementation.md` for the routing contract. - The CEO delegates by creating tasks assigned to the CTO - The CTO breaks those down into sub-tasks assigned to engineers - Discussion happens in task comments, not a side channel diff --git a/packages/shared/src/heartbeat-inbox.ts b/packages/shared/src/heartbeat-inbox.ts new file mode 100644 index 0000000000..256a5c0bb8 --- /dev/null +++ b/packages/shared/src/heartbeat-inbox.ts @@ -0,0 +1,10 @@ +/** Personal inbox routing; company-wide run visibility is unchanged. */ +export function isHeartbeatRunVisibleInMine( + run: { responsibleUserId?: string | null }, + currentUserId: string | null | undefined, +): boolean { + if (!currentUserId) return false; + if (run.responsibleUserId) return run.responsibleUserId === currentUserId; + // Preserve unattributed historical runs for the single-user local board. + return currentUserId === "local-board"; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9591a8ce37..5407976831 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2796,3 +2796,5 @@ export { restoreAgentInstructionSchema } from "./validators/agent.js"; export type { AgentInstructionCandidate } from "./types/agent.js"; export { resolveAgentInstructionCandidateSchema, type ResolveAgentInstructionCandidate } from "./validators/agent.js"; + +export { isHeartbeatRunVisibleInMine } from "./heartbeat-inbox.js"; diff --git a/server/src/__tests__/heartbeat-list.test.ts b/server/src/__tests__/heartbeat-list.test.ts index b07c8e4368..245773fe2b 100644 --- a/server/src/__tests__/heartbeat-list.test.ts +++ b/server/src/__tests__/heartbeat-list.test.ts @@ -145,7 +145,7 @@ describeEmbeddedPostgres("heartbeat list", () => { }); }); - it("returns summary list rows without heavy run detail fields", async () => { + it.each([false, true])("preserves run ownership in list rows (summary=%s)", async (summary) => { const companyId = randomUUID(); const agentId = randomUUID(); const issueId = randomUUID(); @@ -176,6 +176,7 @@ describeEmbeddedPostgres("heartbeat list", () => { agentId, invocationSource: "assignment", status: "failed", + responsibleUserId: "run-owner", error: "Failed after doing useful work", usageJson: { provider: "openai", @@ -199,7 +200,7 @@ describeEmbeddedPostgres("heartbeat list", () => { }, }); - const runs = await heartbeatService(db).list(companyId, undefined, 5, { summary: true }); + const runs = await heartbeatService(db).list(companyId, undefined, 5, { summary }); expect(runs).toHaveLength(1); expect(runs[0]).toMatchObject({ @@ -207,16 +208,19 @@ describeEmbeddedPostgres("heartbeat list", () => { companyId, agentId, status: "failed", + responsibleUserId: "run-owner", error: "Failed after doing useful work", - usageJson: null, - resultJson: null, - sessionIdBefore: null, - sessionIdAfter: null, - logStore: null, - logRef: null, - logSha256: null, - externalRunId: null, - processPid: null, + ...(summary ? { + usageJson: null, + resultJson: null, + sessionIdBefore: null, + sessionIdAfter: null, + logStore: null, + logRef: null, + logSha256: null, + externalRunId: null, + processPid: null, + } : {}), contextSnapshot: { issueId, wakeReason: "issue_assigned", diff --git a/server/src/__tests__/inbox-dismissals.test.ts b/server/src/__tests__/inbox-dismissals.test.ts index 193c6f11a3..937d8b7cc8 100644 --- a/server/src/__tests__/inbox-dismissals.test.ts +++ b/server/src/__tests__/inbox-dismissals.test.ts @@ -20,6 +20,7 @@ import { import { errorHandler } from "../middleware/index.js"; import { inboxDismissalRoutes } from "../routes/inbox-dismissals.js"; import { inboxDismissalService } from "../services/inbox-dismissals.ts"; +import { sidebarBadgeRoutes } from "../routes/sidebar-badges.js"; import { sidebarBadgeService } from "../services/sidebar-badges.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); @@ -59,6 +60,46 @@ describeEmbeddedPostgres("inbox dismissals", () => { await tempDb?.cleanup(); }); + it.each([ + { userId: "user-1", expected: 1 }, + { userId: "user-2", expected: 1 }, + { userId: "uninvolved-user", expected: 0 }, + { userId: "local-board", expected: 1 }, + { userId: "user-1", actorType: "agent", expected: 1 }, + { userId: null, actorType: "agent", expected: 0 }, + ])("scopes failed-run badges to $userId (actor=$actorType)", async ({ userId, actorType, expected }) => { + const companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: "PAP" }); + for (const responsibleUserId of ["user-1", "user-2", null]) { + const agentId = randomUUID(); + await db.insert(agents).values({ id: agentId, companyId, name: "Agent", role: "engineer", status: "error" }); + await db.insert(heartbeatRuns).values({ + companyId, agentId, responsibleUserId, invocationSource: "manual", + status: responsibleUserId === "user-2" ? "timed_out" : "failed", + }); + } + // Pick the latest run before filtering by user, so a shared agent cannot + // resurrect this user's older failure after somebody else's success. + const sharedAgentId = randomUUID(); + await db.insert(agents).values({ id: sharedAgentId, companyId, name: "Shared", role: "engineer" }); + await db.insert(heartbeatRuns).values([ + { companyId, agentId: sharedAgentId, responsibleUserId: "user-1", invocationSource: "manual", status: "failed", createdAt: new Date("2026-03-11T01:00:00Z") }, + { companyId, agentId: sharedAgentId, responsibleUserId: "user-2", invocationSource: "manual", status: "succeeded", createdAt: new Date("2026-03-11T02:00:00Z") }, + ]); + const app = express(); + app.use((req, _res, next) => { + req.actor = actorType === "agent" + ? { type: "agent", source: "agent_jwt", agentId: sharedAgentId, companyId, onBehalfOfUserId: userId, onBehalfOfMemberships: [{ companyId, membershipRole: "member", status: "active" }] } + : { type: "board", source: userId === "local-board" ? "local_implicit" : "session", userId: userId!, companyIds: [companyId], isInstanceAdmin: true }; + next(); + }); + app.use("/api", sidebarBadgeRoutes(db)); + app.use(errorHandler); + const response = await request(app).get(`/api/companies/${companyId}/sidebar-badges`).expect(200); + expect(response.body.failedRuns).toBe(expected); + expect(response.body.inbox).toBe(expected); + }); + it("upserts a single dismissal record per user and inbox item key", async () => { const companyId = randomUUID(); const userId = "board-user"; diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index 770bbc2847..d2d20c956b 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -4,7 +4,6 @@ import { and, eq } from "drizzle-orm"; import { inboxDismissals, joinRequests } from "@paperclipai/db"; import { sidebarBadgeService } from "../services/sidebar-badges.js"; import { accessService } from "../services/access.js"; -import { dashboardService } from "../services/dashboard.js"; import { collapseDuplicatePendingHumanJoinRequests } from "../lib/join-request-dedupe.js"; import { assertCompanyAccess } from "./authz.js"; @@ -28,7 +27,6 @@ export function sidebarBadgeRoutes(db: Db) { const router = Router(); const svc = sidebarBadgeService(db); const access = accessService(db); - const dashboard = dashboardService(db); router.get("/companies/:companyId/sidebar-badges", async (req, res) => { const companyId = req.params.companyId as string; @@ -78,16 +76,14 @@ export function sidebarBadgeRoutes(db: Db) { .then(buildDismissedAtByKey) : new Map(); + // Company health alerts belong in All, not the personal inbox badge. const badges = await svc.get(companyId, { + currentUserId: req.actor.type === "board" + ? req.actor.userId ?? null + : req.actor.onBehalfOfUserId ?? null, dismissals: dismissedAtByKey, joinRequests: visibleJoinRequests, }); - const summary = await dashboard.summary(companyId); - const hasFailedRuns = badges.failedRuns > 0; - const alertsCount = - (summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) + - (summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0); - badges.inbox = badges.failedRuns + alertsCount + badges.joinRequests + badges.approvals; res.json(badges); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 981ac9713a..31226e6fe4 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3311,6 +3311,7 @@ const heartbeatRunProcessGroupIdColumn = const heartbeatRunListColumns = { id: heartbeatRuns.id, + responsibleUserId: heartbeatRuns.responsibleUserId, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId, invocationSource: heartbeatRuns.invocationSource, diff --git a/server/src/services/sidebar-badges.ts b/server/src/services/sidebar-badges.ts index 5849f2ac6f..a53bb36d3e 100644 --- a/server/src/services/sidebar-badges.ts +++ b/server/src/services/sidebar-badges.ts @@ -1,7 +1,7 @@ import { and, desc, eq, inArray, not } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, approvals, heartbeatRuns } from "@paperclipai/db"; -import type { SidebarBadges } from "@paperclipai/shared"; +import { isHeartbeatRunVisibleInMine, type SidebarBadges } from "@paperclipai/shared"; const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"]; const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"]; @@ -27,6 +27,7 @@ export function sidebarBadgeService(db: Db) { get: async ( companyId: string, extra?: { + currentUserId?: string | null; dismissals?: ReadonlyMap; joinRequests?: Array<{ id: string; updatedAt: Date | string | null; createdAt: Date | string }>; unreadTouchedIssues?: number; @@ -49,6 +50,7 @@ export function sidebarBadgeService(db: Db) { .selectDistinctOn([heartbeatRuns.agentId], { id: heartbeatRuns.id, runStatus: heartbeatRuns.status, + responsibleUserId: heartbeatRuns.responsibleUserId, createdAt: heartbeatRuns.createdAt, }) .from(heartbeatRuns) @@ -64,6 +66,7 @@ export function sidebarBadgeService(db: Db) { const failedRuns = latestRunByAgent.filter((row) => FAILED_HEARTBEAT_STATUSES.includes(row.runStatus) + && (extra?.currentUserId === undefined || isHeartbeatRunVisibleInMine(row, extra.currentUserId)) && !isDismissed(extra?.dismissals ?? new Map(), `run:${row.id}`, row.createdAt), ).length; diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 3fdf2ef02a..421cb4dc9d 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -135,7 +135,7 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string, id, companyId: "company-1", agentId, - responsibleUserId: null, + responsibleUserId: "user-1", invocationSource: "assignment", triggerDetail: null, status, @@ -316,6 +316,28 @@ describe("inbox helpers", () => { storage.clear(); }); + it.each([ + { currentUserId: "user-1", expected: 1 }, + { currentUserId: "user-2", expected: 1 }, + { currentUserId: "local-board", expected: 1 }, + { currentUserId: null, expected: 0 }, + ])("counts only personal failed runs for $currentUserId", ({ currentUserId, expected }) => { + const result = computeInboxBadgeData({ + approvals: [], joinRequests: [], dashboard, mineIssues: [], + dismissedAlerts: new Set(), dismissedAtByKey: new Map(), currentUserId, + heartbeatRuns: [ + { ...makeRun("own", "failed", "2026-03-11T01:00:00Z"), responsibleUserId: "user-1" }, + { ...makeRun("other", "timed_out", "2026-03-11T01:00:00Z", "agent-2"), responsibleUserId: "user-2" }, + { ...makeRun("unowned", "failed", "2026-03-11T01:00:00Z", "agent-3"), responsibleUserId: null }, + { ...makeRun("old-own", "failed", "2026-03-11T01:00:00Z", "shared-agent"), responsibleUserId: "user-1" }, + { ...makeRun("new-other", "succeeded", "2026-03-11T02:00:00Z", "shared-agent"), responsibleUserId: "user-2" }, + ], + }); + expect(result.failedRuns).toBe(expected); + expect(result.inbox).toBe(expected); + expect(result.alerts).toBe(1); // The budget alert; run failures already describe agent errors in All. + }); + it("counts the same inbox sources the badge uses", () => { const result = computeInboxBadgeData({ approvals: [ diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 284a1163a2..c95b015352 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -1,3 +1,4 @@ +import { isHeartbeatRunVisibleInMine } from "@paperclipai/shared"; import type { Approval, DashboardSummary, @@ -1286,9 +1287,10 @@ export function computeInboxBadgeData({ ACTIONABLE_APPROVAL_STATUSES.has(approval.status) && !isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt), ).length; - const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter( + const visibleFailedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter( (run) => !isInboxEntityDismissed(dismissedAtByKey, `run:${run.id}`, run.createdAt), - ).length; + ); + const failedRuns = visibleFailedRuns.filter((run) => isHeartbeatRunVisibleInMine(run, currentUserId)).length; const visibleJoinRequests = joinRequests.filter( (jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt), ).length; @@ -1298,7 +1300,7 @@ export function computeInboxBadgeData({ const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0; const showAggregateAgentError = agentErrorCount > 0 && - failedRuns === 0 && + visibleFailedRuns.length === 0 && !dismissedAlerts.has("alert:agent-errors"); const showBudgetAlert = monthBudgetCents > 0 && diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index 2cd0a36ada..829b93bd1e 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -389,6 +389,44 @@ describe("Inbox toolbar", () => { container.remove(); }); + it.each([ + { tab: "mine", userId: "user-1", visible: ["own-failure"], hidden: ["other-failure", "unowned-failure"] }, + { tab: "mine", userId: "user-2", visible: ["other-failure"], hidden: ["own-failure", "unowned-failure"] }, + { tab: "mine", userId: "local-board", visible: ["unowned-failure"], hidden: ["own-failure", "other-failure"] }, + { tab: "mine", userId: null, visible: [], hidden: ["own-failure", "other-failure", "unowned-failure"] }, + { tab: "all", userId: "user-1", visible: ["own-failure", "other-failure", "unowned-failure"], hidden: [] }, + ].flatMap((scenario) => [true, false].map((streamlined) => ({ ...scenario, streamlined }))))( + "scopes failed runs on $tab for $userId (streamlined=$streamlined)", + async ({ tab, userId, visible, hidden, streamlined }) => { + apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false, enableStreamlinedUi: streamlined }); + routerMock.location.pathname = `/inbox/${tab}`; + apiMocks.authSession.mockResolvedValue(userId ? { user: { id: userId }, session: { userId } } : null); + apiMocks.heartbeatRunsList.mockResolvedValue([ + createFailedRun({ id: "own-failure", agentId: "agent-1", responsibleUserId: "user-1" }), + createFailedRun({ id: "other-failure", agentId: "agent-2", responsibleUserId: "user-2", status: "timed_out" }), + createFailedRun({ id: "unowned-failure", agentId: "agent-3" }), + ]); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } }, + }); + const root = createRoot(container); + try { + await act(async () => { + root.render(); + }); + await vi.waitFor(() => { + expect(apiMocks.heartbeatRunsList).toHaveBeenCalled(); + expect(queryClient.isFetching()).toBe(0); + for (const id of visible) expect(container.querySelector(`a[to$="/runs/${id}"]`)).not.toBeNull(); + for (const id of hidden) expect(container.querySelector(`a[to$="/runs/${id}"]`)).toBeNull(); + }); + } finally { + act(() => root.unmount()); + queryClient.clear(); + } + }, + ); + it("restores the legacy toolbar and issue-row presentation when Streamlined UI is off", async () => { routerMock.location.pathname = "/inbox/mine"; apiMocks.experimentalSettings.mockResolvedValue({ diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index c597a91405..1dcbe23af8 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1,7 +1,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { deriveOriginatingActor, INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; +import { deriveOriginatingActor, INBOX_MINE_ISSUE_STATUS_FILTER, isHeartbeatRunVisibleInMine } from "@paperclipai/shared"; import { usePublishSharedQueryData, useSharedPollingQuery } from "@/hooks/useSharedPolling"; import { approvalsApi } from "../api/approvals"; import { accessApi } from "../api/access"; @@ -1328,8 +1328,9 @@ function StreamlinedInbox() { const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; const failedRunsForTab = useMemo(() => { if (tab === "all" && !showFailedRunsCategory) return []; + if (tab === "mine") return failedRuns.filter((run) => isHeartbeatRunVisibleInMine(run, currentUserId)); return failedRuns; - }, [failedRuns, tab, showFailedRunsCategory]); + }, [failedRuns, tab, showFailedRunsCategory, currentUserId]); const joinRequestsForTab = useMemo(() => { if (tab === "all" && !showJoinRequestsCategory) return []; diff --git a/ui/src/pages/LegacyInbox.tsx b/ui/src/pages/LegacyInbox.tsx index 988110240d..a7a34e0096 100644 --- a/ui/src/pages/LegacyInbox.tsx +++ b/ui/src/pages/LegacyInbox.tsx @@ -1,7 +1,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { deriveOriginatingActor, INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; +import { deriveOriginatingActor, INBOX_MINE_ISSUE_STATUS_FILTER, isHeartbeatRunVisibleInMine } from "@paperclipai/shared"; import { usePublishSharedQueryData, useSharedPollingQuery } from "@/hooks/useSharedPolling"; import { approvalsApi } from "../api/approvals"; import { accessApi } from "../api/access"; @@ -1213,8 +1213,9 @@ export function Inbox() { const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; const failedRunsForTab = useMemo(() => { if (tab === "all" && !showFailedRunsCategory) return []; + if (tab === "mine") return failedRuns.filter((run) => isHeartbeatRunVisibleInMine(run, currentUserId)); return failedRuns; - }, [failedRuns, tab, showFailedRunsCategory]); + }, [failedRuns, tab, showFailedRunsCategory, currentUserId]); const joinRequestsForTab = useMemo(() => { if (tab === "all" && !showJoinRequestsCategory) return [];