perf(tools): index pending profile review scopes (#18681)

## Thinking Path

> - Paperclip manages AI agents and their work.
> - The Apps UI lists tool profiles and shows new tools that need
review.
> - Both services project new-tool review for each profile.
> - The projection scans include rules and exact rules for each catalog
row.
> - This repeats millions of comparisons when many selected profiles
exist.
> - This pull request builds scope and selector indexes once per
projection.
> - The benefit is less server CPU with the same fresh review results.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

New-tool review projection in profile listings, profile review, and app
attention.

**Current behavior**

Each newer catalog row scans the profile's included scopes. Included
catalog entries require a map lookup for each scope comparison. Rows in
scope then scan all rules again to omit tools that were already
reviewed. Profile listing and app attention repeat this work for every
profile.

**Proposed behavior**

Index covered application and connection scopes once. Expand included
catalog entries to their existing connection and non-null application.
Index exact selectors across all rules once. Keep the ordered catalog
filter and returned metadata.

**Reason and benefit**

A recent main-thread profile identified this scope-matching path as a
large CPU consumer. The local reproduction below confirms multi-second
CPU work in the actual owning services. This PR removes the repeated
scans without caching authorization or changing database reads.

Related [#18673](https://github.com/paperclipai/paperclip/pull/18673)
indexes effective access and profile summaries. It leaves this
pending-review path unchanged. This PR targets the separate
pending-review mechanism and has no dependency on that PR. The selector
helper is identical so the owners can share it when both changes land.

## What Changed

- Build covered application and connection sets for included scopes.
- Build exact selector sets for all rules, including exclusions, to omit
already reviewed tools.
- Preserve null application selectors, missing catalog references,
review watermarks, status checks, output order, and names.
- Add differential tests against the former scope/matching evaluator.
- Add PostgreSQL checks through all three affected service methods,
including current catalog and rule changes.
- Add a reproducible PostgreSQL benchmark of complete profile-list and
app-attention results.

## Verification

Baseline: `1f8f2e195f4897cd1c237bdc068de95780ab0c3c`. Treatment:
`fd59c0183ce64c44696f79604516f4d51ca5662b`. Node 24.22.2 on macOS arm64.
The production projection file at this baseline is identical to the file
at `410b0e27015495ab8951768b5b0b94727de33785`.

The benchmark exercises the real
`createToolProfileService(...).listProfiles` and
`createToolAppAttention(...)` methods against disposable PostgreSQL. It
uses 200 selected profiles, 1,900 active tools, 76,000 rules, and 12
agents. Profiles alternate between 65 and 695 rules. Half reference 695
removed catalog entries; half select current tools. This tests both the
slow failed scope scans and the slow exact-rule scans. No provider calls
or hosted traffic are generated.

The complete ordered results from both services match baseline. After
one equality/warmup invocation per version, five samples alternate
implementation order. Forced GC runs before each measured pair of
service calls. Other workers paused owned builds and benchmarks;
unrelated host activity was not controlled.

| One profile listing plus one attention listing | Baseline median
(range) | Change median (range) |
| --- | --- | --- |
| Process CPU | 5,406.512 ms (5,221.533–5,648.262) | 1,770.116 ms
(1,596.232–2,045.617) |
| Elapsed time | 5,279.231 ms (4,932.419–5,537.105) | 1,532.414 ms
(1,499.199–1,610.428) |
| Queries | 14 | 14 |
| Returned database rows | 156,215 | 156,215 |

Median process CPU falls **67.3%** and median elapsed time falls
**71.0%**. Profile summaries retain their old nested matching in this
comparison, so the result isolates pending-review indexing. Query counts
and row counts do not decrease. This is local source/tsx service proof.
It does not establish hosted improvement, whole-run latency, or HTTP
tail latency.

Reproduce with `server/scripts/benchmark-profile-review-projection.ts`;
baseline-module setup is documented in
`doc/mcp-discovery-performance.md`.

Passed:

```sh
node node_modules/vitest/vitest.mjs run server/src/__tests__/tool-profile-pending-projection.test.ts server/src/__tests__/tool-profile-review-service.test.ts server/src/__tests__/tool-profile-entry-order.test.ts
node scripts/check-module-boundaries.mjs
```

All five tests in three suites passed. The differential tests cover all
selector kinds, include/exclude effects, null scope behavior, missing
references, review watermarks, and exact special-character names.
PostgreSQL checks preserve current pending counts after exclusions,
catalog removal, and review; another company remains separate. `git diff
--check` passes.

The first new integration assertion used `summary` instead of the
existing attention response's `totals` field. It failed before the
mutation checks. The test assertion was corrected; no production code
changed for that correction. The final run above passed.

Server typecheck was attempted but did not pass local setup. The
packaged command did not reach TypeScript because a global pnpm child
tried to fetch and verify the project's pnpm version through an
unavailable registry. A direct Node 24 TypeScript invocation then
reported missing built `@paperclipai/paperclip-runner` and related
package declarations, with cascading type errors in unchanged consumers.
No error points to the changed projection. The prerequisite build was
not expanded into a full workspace build. Full local unsharded tests and
build were not run; final-head CI must supply broad acceptance. No merge
or deployment was performed.

## Risks

Scope expansion must preserve existing semantics. An individual catalog
rule covers its connection and only a non-null application. An explicit
null application selector still matches null applications. Removed
references contribute no scope when absent from the supplied catalog.
The oracle and PostgreSQL tests cover these boundaries.

The indexes add linear temporary memory in rule count and live only for
one projection. Current company-scoped database reads and review
mutations remain in their owners. Exclusions remain explicitly reviewed;
they do not create new review items. Revert this commit to roll back.

## Model Used

OpenAI GPT-6.1 Sol (`gpt-6.1-sol`) with source inspection, code
execution, tests, and local PostgreSQL benchmarking. The context window
size is not available 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
- [ ] 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 16:10:42 -06:00
1 parent 2b374caba0
commit 482706e7dd
6 files changed
+260 -23

No files matched your search

@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { isDeepStrictEqual } from "node:util";
import { pathToFileURL } from "node:url";
import { createDb, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db";
import { createToolProfileService } from "../src/services/tool-access/profiles/service.js";
import { createToolAppAttention } from "../src/services/tool-access/catalog/attention.js";
import { createProfileProjectionFixture } from "../src/__tests__/helpers/tool-profile-projection-fixture.js";
const option = (name: string, fallback: string) => { const i = process.argv.indexOf(name); return i < 0 ? fallback : process.argv[i + 1] ?? fallback; };
const baselineProfiles = option("--baseline-profiles", ""), baselineAttention = option("--baseline-attention", "");
if (!baselineProfiles || !baselineAttention) throw new Error("--baseline-profiles and --baseline-attention modules required");
const beforeProfiles = (await import(pathToFileURL(baselineProfiles).href)).createToolProfileService as typeof createToolProfileService;
const beforeAttention = (await import(pathToFileURL(baselineAttention).href)).createToolAppAttention as typeof createToolAppAttention;
const profiles = Number(option("--profiles", "200")), repeats = Number(option("--repeat", "5"));
if (!Number.isInteger(profiles) || profiles < 2 || !Number.isInteger(repeats) || repeats < 1) throw new Error("Positive fixture counts required");
const database = await startEmbeddedPostgresTestDatabase("profile-review-projection-");
const db = createDb(database.connectionString, { maxConnections: 1 });
let queries = 0, rows = 0;
const measuredDb = new Proxy(db, { get(target, key) {
if (key !== "select") return Reflect.get(target, key);
return (...fields: Parameters<Db["select"]>) => {
const builder = target.select(...fields), from = builder.from.bind(builder);
builder.from = ((...args: Parameters<typeof from>) => {
const selection = from(...args), execute = selection.execute.bind(selection);
selection.execute = async () => { const result = await execute(); queries++; rows += result.length; return result; };
return selection;
}) as typeof builder.from;
return builder;
};
} });
try {
const fixture = await createProfileProjectionFixture(db, { profiles });
const now = () => new Date("2026-02-01T00:00:00Z");
const implementations = {
before: { profiles: beforeProfiles(measuredDb, now), attention: beforeAttention(measuredDb, now) },
after: { profiles: createToolProfileService(measuredDb, now), attention: createToolAppAttention(measuredDb, now) },
};
const invoke = async (label: keyof typeof implementations) => {
const service = implementations[label];
return { profiles: await service.profiles.listProfiles(fixture.company.id), attention: await service.attention(fixture.company.id) };
};
const baseline = await invoke("before"), treatment = await invoke("after");
assert.ok(isDeepStrictEqual(baseline, treatment), "Complete profile and attention results must match baseline");
console.log(JSON.stringify({ fixture: { profiles, tools: 1900, meanRulesPerProfile: 380, ruleCounts: [65, 695], removedTools: 695, agents: 12, staleReferenceProfiles: Math.floor(profiles / 2) }, equality: true, profilePendingCounts: [...new Set(treatment.profiles.map(p => p.newToolsPendingCount))] }));
for (let round = 0; round < repeats; round++) {
for (const label of round % 2 ? ["after", "before"] as const : ["before", "after"] as const) {
global.gc?.(); queries = 0; rows = 0;
const start = performance.now(), cpu = process.cpuUsage();
const result = await invoke(label);
const elapsedMs = performance.now() - start, used = process.cpuUsage(cpu);
assert.equal(result.profiles.length, profiles);
console.log(JSON.stringify({ round, label, elapsedMs, cpuMs: (used.user + used.system) / 1000, queries, rows, profiles, forcedGc: Boolean(global.gc) }));
}
}
} finally { await db.$client.end({ timeout: 1 }); await database.cleanup(); }