mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 01:46:15 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Mine inbox shows work that needs the current user. > - Failed-run rows used the latest run for every agent in the company. > - A failure from another user therefore appeared in Mine and its badge. > - Run list responses also omitted the responsible user needed to filter these rows. > - This pull request uses run ownership for personal failure routing. > - Users see their own failures and can still inspect company failures in All. ## Linked Issues or Issue Description **What happened?** An agent run started for one user failed. Its row and failure badge appeared in another user's Mine inbox. **Expected behavior** Mine and its badge include failed runs for the current responsible user. Other users' failures remain available in All and run details. **Steps to reproduce** 1. Use a company with two human users. 2. Create a failed or timed-out run attributed to the first user. 3. Open Mine as the second user. Before this fix, the failed run appears there and increases the badge. **Paperclip version or commit** Reproduced in regression tests on master at `24beb0057`. **Deployment mode** Authenticated deployment with multiple users. Tests also cover the local single-user board. Related prior work: #933 addressed inbox dismissal and badge consistency. No duplicate ownership fix was found. ## What Changed - Return `responsibleUserId` in normal and summary run lists. - Share one ownership rule across both inbox versions and client/server badges. - Select the latest run per agent before applying the ownership filter. This prevents old failures from resurfacing on shared agents. - Keep unattributed historical failures in the local board's Mine view. Hide them from authenticated users with no matching owner. - Keep company health alerts outside the personal badge, consistent with the client. - Document the routing contract and add page, badge, and database regression coverage. ## Verification - Red: the new badge cases failed with three company failures instead of one personal failure; eight Mine page cases failed across both inbox versions. - Green: 113 focused tests pass in `ui/src/lib/inbox.test.ts`, `ui/src/pages/Inbox.test.tsx`, `server/src/__tests__/heartbeat-list.test.ts`, and `server/src/__tests__/inbox-dismissals.test.ts`. - `pnpm check:token-gates` passes. - Agent calls on behalf of a user have two additional red-to-green API regressions. - Full `pnpm -r typecheck` and `pnpm build` pass. Server typecheck also passes after the agent-call fix. - All CI test shards and browser tests pass on `243bfa681`. The duplicate local `pnpm test:run` was stopped after the CI test lanes completed; it did not finish locally. ## Risks - Authenticated users no longer receive unattributed legacy failures in Mine. Those failures remain visible in All. - The server badge no longer counts company health alerts, matching the existing client badge. - No migration, run state, retry behavior, or company access rules change. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, terminal execution, and browser tools. The exact deployment variant and context window size are not exposed in this session. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
90 lines
3.2 KiB
TypeScript
90 lines
3.2 KiB
TypeScript
import { and, desc, eq, inArray, not } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { agents, approvals, heartbeatRuns } from "@paperclipai/db";
|
|
import { isHeartbeatRunVisibleInMine, type SidebarBadges } from "@paperclipai/shared";
|
|
|
|
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
|
|
const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
|
|
|
|
function normalizeTimestamp(value: Date | string | null | undefined): number {
|
|
if (!value) return 0;
|
|
const timestamp = new Date(value).getTime();
|
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
}
|
|
|
|
function isDismissed(
|
|
dismissedAtByKey: ReadonlyMap<string, number>,
|
|
itemKey: string,
|
|
activityAt: Date | string | null | undefined,
|
|
) {
|
|
const dismissedAt = dismissedAtByKey.get(itemKey);
|
|
if (dismissedAt == null) return false;
|
|
return dismissedAt >= normalizeTimestamp(activityAt);
|
|
}
|
|
|
|
export function sidebarBadgeService(db: Db) {
|
|
return {
|
|
get: async (
|
|
companyId: string,
|
|
extra?: {
|
|
currentUserId?: string | null;
|
|
dismissals?: ReadonlyMap<string, number>;
|
|
joinRequests?: Array<{ id: string; updatedAt: Date | string | null; createdAt: Date | string }>;
|
|
unreadTouchedIssues?: number;
|
|
},
|
|
): Promise<SidebarBadges> => {
|
|
const actionableApprovals = await db
|
|
.select({ id: approvals.id, updatedAt: approvals.updatedAt })
|
|
.from(approvals)
|
|
.where(
|
|
and(
|
|
eq(approvals.companyId, companyId),
|
|
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES),
|
|
),
|
|
)
|
|
.then((rows) =>
|
|
rows.filter((row) => !isDismissed(extra?.dismissals ?? new Map(), `approval:${row.id}`, row.updatedAt)).length
|
|
);
|
|
|
|
const latestRunByAgent = await db
|
|
.selectDistinctOn([heartbeatRuns.agentId], {
|
|
id: heartbeatRuns.id,
|
|
runStatus: heartbeatRuns.status,
|
|
responsibleUserId: heartbeatRuns.responsibleUserId,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
eq(agents.companyId, companyId),
|
|
not(eq(agents.status, "terminated")),
|
|
),
|
|
)
|
|
.orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt));
|
|
|
|
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;
|
|
|
|
const joinRequests = (extra?.joinRequests ?? []).filter((row) =>
|
|
!isDismissed(
|
|
extra?.dismissals ?? new Map(),
|
|
`join:${row.id}`,
|
|
row.updatedAt ?? row.createdAt,
|
|
)
|
|
).length;
|
|
const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0;
|
|
return {
|
|
inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues,
|
|
approvals: actionableApprovals,
|
|
failedRuns,
|
|
joinRequests,
|
|
};
|
|
},
|
|
};
|
|
}
|