perf(apps): scope sidebar review counts to one connection (#18688)

## Thinking Path

> - Paperclip helps people manage AI agents and their governed app
access.
> - The Apps sidebar displays a Review badge for one connection.
> - It polls the full company attention service every 30 seconds to get
two counts.
> - That service also loads every profile, rule and active catalog
entry.
> - This pull request adds an authorized connection read for only those
counts.
> - The same badge now avoids unrelated profile evaluation and database
result decoding.

## Linked Issues or Issue Description

**Current behavior**

The contextual sidebar polls company-wide attention. It needs only one
connection's signed pending action count and whether any catalog entries
are quarantined.

**Proposed behavior**

Both sidebar variants poll a connection-scoped count endpoint with
existing connection read authority. The full attention API stays
unchanged.

**Reason and benefit**

The retained 200-profile fixture performs seven service queries and
returns 80,115 rows for this badge. The new reader performs one query
and returns one row. This reduces local service CPU and avoids
unnecessary decoded profile/rule data. Related merged evaluator fixes
are already in the baseline: Refs #18673, Refs #18681. A search found no
existing PR for this scoped count reader. ROADMAP.md contains no
duplicate planned work.

## What Changed

- Add the narrow shared DTO, typed client, guarded REST/OpenAPI route
and catalog-owned reader.
- Count independent aggregates with both company boundaries and exact
signed-argument semantics. Keep archived counts at zero.
- Update both contextual sidebar variants. Keep the exact badge, 30
second interval and existing attention-prefix invalidation.
- Add PostgreSQL differential tests, authenticated HTTP tests, sidebar
tests, a repeatable benchmark and access documentation.

## Verification

- Baseline: `3f8ebfff8aa9febd0733c6f65b07485dcadfd0e2`, including both
merged evaluator fixes. Service treatment: `0827f7d0b3`; route/fixture
refinement: `230c42ee15`; final local existence gate: `390a218254`.
- Passed: focused PostgreSQL differential test; assembled HTTP test with
board session and agent JWT; 14 sidebar component tests; token gates;
map checks; diff check.
- Fixture: 200 profiles, 76,000 rules, 1,900 active tools, 695 removed
references, 12 agents, a sibling connection and 2,000 unrelated
historical invocations. Complete normalized two-count results are equal
on both paths.
- Quiet local measurement: seven alternating warm pairs after two warmup
pairs, Node 24.22.2, macOS, real disposable PostgreSQL. Legacy/new
service CPU median: 213.891/1.680 ms; ranges:
209.329–228.552/0.515–6.137 ms. Wall median: 156.867/1.248 ms; ranges:
151.739–162.289/1.115–1.386 ms. Queries: 7/1. Returned rows: 80,115/1.
- Run `cd server && node --expose-gc --import
./node_modules/tsx/dist/loader.mjs
scripts/benchmark-connection-review-counts.ts`. See
`doc/connection-review-counts.md` for scope and failure history.
Byte-volume accounting runs outside timing; no HTTP payload reduction is
claimed.
- Initial raw scalar SQL failed with ambiguous columns. Typed subqueries
fixed it and passed real PostgreSQL equality. Initial fixture agents
lacked permissions and correctly received 403. The final fixture grants
explicit authority and tests revocation. An initial benchmark included
byte serialization in timing; the reported run excludes it.
- Passed required local `pnpm -r typecheck` and `pnpm build`. The
initial sandbox typecheck could not open the tsx IPC socket; the
escalated rerun passed. Full monolithic `pnpm test:run` was not run
locally: repeating a 15–25 minute suite serially across both performance
PRs adds no distinct evidence. Broad suite acceptance uses final-head
CI, which runs the same general/serialized test partitions and the
native Runner lane. Final-head CI is in progress.
- Passed built-static Chromium journey on an owned local-trusted server
and disposable PostgreSQL: Review 3 appears, only scoped counts are
requested by this sidebar, resolving requests/clearing quarantines then
reloading removes the badge, and archived counts are zero. The first ad
hoc launcher omitted required decision-service options and failed at
startup; using the normal no-op wake callback fixed that harness.
- Greptile identified an invalid resolved-request fixture label.
Corrected `denied` to the declared `rejected`, then reran the real
PostgreSQL differential test. The count SQL and non-pending exclusion
are unchanged.
- The first final-head CI failed the static cross-tenant
existence-oracle guard because the new explicit resource company
assertion had no nearby `hasCompanyAccess` gate. Existing management
admission already rejects foreign resources with 404. The route now also
follows the standard local two-step gate. The unchanged guard plus
authenticated HTTP negative cases passed (3 tests). No allowlist or test
was weakened.
- These local service measurements do not prove hosted performance. No
deployment is included.

## Risks

Low risk. New read admission reuses existing current-authority checks.
The old attention API is unchanged. Counts still require database work
over relevant pending/catalog rows. No cache, schema migration or
telemetry is added. Roll back this PR if the badge differs or scoped
reads deny previously permitted users.

## Model Used

OpenAI `gpt-6.1-sol` (Sol 6.1), with reasoning, tool use and code
execution. The host does not expose the configured context-window size.

## 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
- [ ] All Paperclip CI gates are green
- [ ] 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-02 17:12:22 -06:00
1 parent fd0d61b8e3
commit 58308a3e63
19 files changed
+406 -24

No files matched your search

@@ -0,0 +1,17 @@
import { randomUUID } from "node:crypto";
import { toolActionRequests, toolCatalogEntries, toolConnections, toolInvocations, type Db } from "@paperclipai/db";
import { createProfileProjectionFixture } from "./tool-profile-projection-fixture.js";
export async function createConnectionReviewCountsFixture(db: Db, large = false) {
const f = await createProfileProjectionFixture(db, large ? {} : { profiles: 4, tools: 12, rules: 4 });
const [sibling] = await db.insert(toolConnections).values({ companyId: f.company.id, applicationId: f.application.id, name: "Sibling", uid: randomUUID(), transport: "mcp_remote", status: "active", healthStatus: "error" }).returning();
await db.insert(toolCatalogEntries).values([f.connection, sibling].flatMap(connection => Array.from({ length: 3 }, (_, i) => ({ companyId: f.company.id, connectionId: connection.id, name: `quarantine_${i}`, toolName: `quarantine_${i}`, versionHash: "v1", status: "quarantined" as const }))));
const [invocation, siblingInvocation, nullInvocation] = await db.insert(toolInvocations).values([f.connection.id, sibling.id, null].map(connectionId => ({ companyId: f.company.id, connectionId, toolName: "review_me" }))).returning();
const request = (invocationId: string, signedArguments: string | null, status: "pending" | "rejected" = "pending") => ({ companyId: f.company.id, invocationId, signedArguments, status, canonicalArgumentsHash: "hash", canonicalArgumentsSummary: { summary: "Synthetic review request" } });
const requests = await db.insert(toolActionRequests).values([
request(invocation.id, "signed"), request(invocation.id, ""), request(invocation.id, null), request(invocation.id, "signed", "rejected"),
request(siblingInvocation.id, "signed"), request(nullInvocation.id, "signed"),
]).returning();
if (large) await db.insert(toolInvocations).values(Array.from({ length: 2000 }, () => ({ companyId: f.company.id, connectionId: sibling.id, toolName: "history", status: "succeeded" as const })));
return { ...f, sibling, invocation, requests };
}
@@ -0,0 +1,56 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { companyMemberships, toolApplications, toolConnections, toolCatalogEntries, connectionGrants, principalPermissionGrants } from "@paperclipai/db";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
import { accessService } from "../services/access.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
describe("review-counts through assembled authenticated HTTP routes", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
beforeAll(async () => {
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", randomUUID());
vi.stubEnv("PAPERCLIP_RESPONSIBLE_USER_AUTHZ_MODE", "shadow");
vi.stubEnv("PAPERCLIP_AUTH_RATE_LIMIT_ENABLED", "false");
server = await startRunnerApiTestServer({ authPublicBaseUrl: "https://paperclip.fixture.invalid", humanSessions: true });
}, 90_000);
afterAll(async () => { await server?.close(); vi.unstubAllEnvs(); });
it("uses current company, personal and private authority for board and actual agent identities", async () => {
const f = await server.fixture({ connectionScenario: "fresh" });
const access = accessService(server.db);
await access.ensureMembership(f.companyId, "agent", f.agentId, "member", "active");
await access.ensureMembership(f.companyId, "user", f.responsibleUserId!, "operator", "active");
await access.ensureRoleDefaultGrants(f.companyId, f.responsibleUserId!, "operator", f.responsibleUserId!);
await server.db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tools:manage_connections", scope: null });
const cookie = await server.humanSession(f.responsibleUserId!);
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId!);
const [application] = await server.db.insert(toolApplications).values({ companyId: f.companyId, name: "Scoped counts", type: "mcp_http" }).returning();
const [connection] = await server.db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id, name: "Scoped counts", uid: `counts-${randomUUID()}`, transport: "mcp_remote", status: "active", createdByUserId: f.responsibleUserId }).returning();
await server.db.insert(toolCatalogEntries).values({ companyId: f.companyId, connectionId: connection.id, name: "Review", toolName: "review", versionHash: "v1", status: "quarantined" });
const read = async (id: string, actor: "board" | "agent" = "agent") => {
const response = await fetch(`${server.apiUrl}/api/tool-connections/${id}/review-counts`, { headers: actor === "board" ? { cookie } : { Authorization: `Bearer ${token}` } });
return { status: response.status, body: await response.json() };
};
for (const actor of ["board", "agent"] as const) {
expect(await read(connection.id, actor)).toEqual({ status: 200, body: { pendingActionRequestCount: 0, quarantinedCatalogEntryCount: 1 } });
expect(await read(connection.uid, actor)).toEqual(await read(connection.id, actor));
expect((await read(randomUUID(), actor)).status).toBe(404);
}
await server.db.update(toolConnections).set({ status: "archived" }).where(eq(toolConnections.id, connection.id));
expect(await read(connection.id)).toEqual({ status: 200, body: { pendingActionRequestCount: 0, quarantinedCatalogEntryCount: 0 } });
await server.db.update(toolConnections).set({ status: "active", credentialPolicy: "per_user", createdByUserId: "another-user" }).where(eq(toolConnections.id, connection.id));
expect((await read(connection.id)).status).toBe(403);
const [grant] = await server.db.insert(connectionGrants).values({ companyId: f.companyId, connectionId: connection.id, kind: "user", subjectUserId: f.responsibleUserId!, status: "active" }).returning();
expect((await read(connection.id)).status).toBe(200);
await server.db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, grant.id));
expect((await read(connection.id)).status).toBe(403);
await server.db.update(toolConnections).set({ credentialPolicy: "shared", createdByUserId: f.responsibleUserId }).where(eq(toolConnections.id, connection.id));
const [foreignApplication] = await server.db.insert(toolApplications).values({ companyId: f.foreignCompanyId, name: "Foreign", type: "mcp_http" }).returning();
const [foreign] = await server.db.insert(toolConnections).values({ applicationId: foreignApplication.id, companyId: f.foreignCompanyId, name: "Foreign", uid: randomUUID(), transport: "mcp_remote" }).returning();
expect([403, 404]).toContain((await read(foreign.id)).status);
await server.db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, f.responsibleUserId!));
await server.db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, f.responsibleUserId!));
expect([403, 404]).toContain((await read(connection.id)).status);
expect([403, 404]).toContain((await read(connection.id, "board")).status);
}, 30_000);
});
@@ -0,0 +1,45 @@
import { and, eq } from "drizzle-orm";
import { beforeAll, afterAll, describe, expect, it } from "vitest";
import { createDb, companies, toolActionRequests, toolCatalogEntries, toolConnections, toolInvocations } from "@paperclipai/db";
import { startRecoveryPostgres } from "./helpers/recovery-postgres.js";
import { createConnectionReviewCountsFixture } from "./helpers/tool-connection-review-counts-fixture.js";
import { createToolAppAttention } from "../services/tool-access/catalog/attention.js";
import { getToolConnectionReviewCounts } from "../services/tool-access/catalog/review-counts.js";
const zero = { pendingActionRequestCount: 0, quarantinedCatalogEntryCount: 0 };
describe("connection review counts on PostgreSQL", () => {
let database: Awaited<ReturnType<typeof startRecoveryPostgres>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => { database = await startRecoveryPostgres("connection-review-counts-"); db = createDb(database.connectionString); }, 90_000);
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
it("preserves the complete legacy badge projection without fanout and recomputes after changes", async () => {
const f = await createConnectionReviewCountsFixture(db);
const legacy = createToolAppAttention(db, () => new Date());
const compare = async (id: string) => {
const item = (await legacy(f.company.id)).apps.find(app => app.connection.id === id);
const expected = item ? { pendingActionRequestCount: item.pendingActionRequestCount, quarantinedCatalogEntryCount: item.quarantinedCatalogEntryCount } : zero;
const result = await getToolConnectionReviewCounts(db, f.company.id, id);
expect(result).toEqual(expected);
return result;
};
expect(await compare(f.connection.id)).toEqual({ pendingActionRequestCount: 2, quarantinedCatalogEntryCount: 3 });
expect(await compare(f.sibling.id)).toEqual({ pendingActionRequestCount: 1, quarantinedCatalogEntryCount: 3 });
const [foreign] = await db.insert(companies).values({ name: "Foreign review counts", issuePrefix: "FRC" }).returning();
// Historical schema permits cross-company references: neither side can contribute.
const [invocation] = await db.insert(toolInvocations).values({ companyId: foreign.id, connectionId: f.connection.id, toolName: "foreign" }).returning();
await db.insert(toolActionRequests).values([
{ companyId: f.company.id, invocationId: invocation.id, signedArguments: "signed", canonicalArgumentsHash: "hash", canonicalArgumentsSummary: { summary: "Synthetic review request" } },
{ companyId: foreign.id, invocationId: f.invocation.id, signedArguments: "signed", canonicalArgumentsHash: "hash", canonicalArgumentsSummary: { summary: "Synthetic review request" } },
]);
await db.insert(toolCatalogEntries).values({ companyId: foreign.id, connectionId: f.connection.id, name: "foreign", toolName: "foreign", versionHash: "v1", status: "quarantined" });
expect(await compare(f.connection.id)).toEqual({ pendingActionRequestCount: 2, quarantinedCatalogEntryCount: 3 });
await db.update(toolConnections).set({ status: "archived" }).where(eq(toolConnections.id, f.connection.id));
expect(await compare(f.connection.id)).toEqual(zero);
await db.update(toolConnections).set({ status: "active" }).where(eq(toolConnections.id, f.connection.id));
expect(await compare(f.connection.id)).toEqual({ pendingActionRequestCount: 2, quarantinedCatalogEntryCount: 3 });
await db.update(toolActionRequests).set({ status: "rejected" }).where(eq(toolActionRequests.companyId, f.company.id));
await db.update(toolCatalogEntries).set({ status: "active" }).where(and(eq(toolCatalogEntries.companyId, f.company.id), eq(toolCatalogEntries.status, "quarantined")));
expect(await compare(f.connection.id)).toEqual(zero);
expect(await compare(f.sibling.id)).toEqual(zero); // unhealthy-only remains a zero badge
});
});
+9
View File
@@ -11769,6 +11769,15 @@ registerCurrentRoute({
summary: "Revoke a tool connection grant",
});
registerCurrentRoute({
"x-paperclip-transport": "rest",
"x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/tool-connections/{connectionId}/review-counts",
tags: ["tool-access"],
summary: "Get pending action request and quarantined catalog entry counts for one connection",
});
registerCurrentRoute({
"x-paperclip-transport": "rest",
"x-paperclip-connection-management": "connections:read",
+8
View File
@@ -1921,6 +1921,14 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(delegation);
});
router.get("/tool-connections/:connectionId/review-counts", managementGuard("connections:read", "GET /tool-connections/:connectionId/review-counts", { personal: true }), async (req, res) => {
assertBoardOrAgent(req);
const { resource } = connectionRequestContext(req)!;
if (!hasCompanyAccess(req, resource.companyId)) throw notFound("Tool connection not found");
assertCompanyAccess(req, resource.companyId);
res.json(await svc.getConnectionReviewCounts(resource.companyId, resource.connectionId!));
});
router.get("/tool-connections/:connectionId/usage", managementGuard("connections:read", "GET /tool-connections/:connectionId/usage", { personal: true }), async (req, res) => {
assertBoardOrAgent(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
+2
View File
@@ -18,6 +18,7 @@ import { isOAuthEndpointRejection } from "./tool-access/catalog/failures.js";
import { createCatalogDefaultAccess, toPolicy } from "./tool-access/catalog/default-access.js";
import { createToolCatalogDiscovery } from "./tool-access/catalog/discovery.js";
import { createConnectionHealth } from "./tool-access/catalog/health.js";
import { getToolConnectionReviewCounts } from "./tool-access/catalog/review-counts.js";
import { createToolAppAttention } from "./tool-access/catalog/attention.js";
import { createToolCatalogPublication } from "./tool-access/catalog/publication.js";
import { createToolCatalogReader } from "./tool-access/catalog/reader.js";
@@ -4737,6 +4738,7 @@ export function toolAccessService(
},
listAppsNeedingAttention,
getConnectionReviewCounts: (companyId: string, connectionId: string) => getToolConnectionReviewCounts(db, companyId, connectionId),
sweepConnectionHealth,
scheduledHealth: connectionHealthSchedule(db, checkConnectionHealth),
@@ -0,0 +1,33 @@
import { and, count, eq, isNotNull, sql } from "drizzle-orm";
import { toolActionRequests, toolCatalogEntries, toolConnections, toolInvocations, type Db } from "@paperclipai/db";
import type { ToolConnectionReviewCounts } from "@paperclipai/shared";
/** Counts for one already-authorized connection. Independent aggregates avoid catalog/request fanout. */
export async function getToolConnectionReviewCounts(
db: Db,
companyId: string,
connectionId: string,
): Promise<ToolConnectionReviewCounts> {
const pending = db.select({ count: count() }).from(toolActionRequests)
.innerJoin(toolInvocations, eq(toolInvocations.id, toolActionRequests.invocationId))
.where(and(
eq(toolActionRequests.companyId, companyId),
eq(toolInvocations.companyId, companyId),
eq(toolInvocations.connectionId, connectionId),
eq(toolActionRequests.status, "pending"),
isNotNull(toolActionRequests.signedArguments),
));
const quarantined = db.select({ count: count() }).from(toolCatalogEntries).where(and(
eq(toolCatalogEntries.companyId, companyId),
eq(toolCatalogEntries.connectionId, connectionId),
eq(toolCatalogEntries.status, "quarantined"),
));
const [counts] = await db.select({
pendingActionRequestCount: sql<number>`case when ${toolConnections.status} = 'archived' then 0 else (${pending}) end`.mapWith(Number),
quarantinedCatalogEntryCount: sql<number>`case when ${toolConnections.status} = 'archived' then 0 else (${quarantined}) end`.mapWith(Number),
}).from(toolConnections).where(and(
eq(toolConnections.companyId, companyId), eq(toolConnections.id, connectionId),
));
// Missing/hidden resources are rejected by route admission before this reader.
return counts ?? { pendingActionRequestCount: 0, quarantinedCatalogEntryCount: 0 };
}