Files
PaperClipAI/server/src/services/tool-access/catalog/reader.ts
T
DottaandPaperclip f7210cd31f refactor(tools): isolate catalog ownership (#17165)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents receive reviewed actions discovered from their connections.
> - Transport, discovery, risk classification, publication and account
inventory lived in the same service.
> - Their ownership differs: transport guards requests, discovery reads
providers, and publication changes the catalog under locks.
> - This pull request gives those phases separate modules with explicit
capabilities.
> - The benefit is a visible discovery-to-publication path that
preserves permission and quarantine behavior.

## Linked Issues or Issue Description

Builds on merged #17162 and preserves the connector policy from #17141.
Earlier stack: #17144 → #17146 → #17147 → #17155 → #17157 → #17159 →
#17161 → #17162 → this PR.

**What existing behavior does this improve?**

Maintenance of tool discovery, catalog publication, health and upstream
account inventory.

**Current behavior**

The 8,988-line service handles HTTP guards, MCP sessions, provider
probes, risk classification, quarantine, cached reads and account sync.

**Proposed behavior**

Discovery returns descriptors through the existing credential and
transport owners. Publication owns catalog writes and default access. A
reader owns refresh deduplication. Health and aggregator inventory have
separate owners.

**Reason and benefit**

The entry point falls to 6,419 lines, a reduction of 2,569. Provider
discovery no longer shares a closure with connection setup and teardown.

## What Changed

- Extract guarded remote HTTP, descriptor policy, discovery,
publication, default access, cached reads, health, attention and
aggregator account sync.
- Keep the public descriptor/risk exports and existing service methods.
- Preserve database locks, transaction-scoped default-access writes,
quarantine decisions, current grant checks and failure classification.
- Preserve per-service catalog flights and aggregator jobs, lease
ownership and live remote-request options.
- New modules range from 39 to 492 lines.

## Verification

- Before and after the move, 470 public service/HTTP integration cases
and 44 publication, HTTP rebinding and aggregator sync cases pass.
- Server typecheck, module-boundary gate and whitespace checks pass.
- All 157 pre-existing function/method bodies match by normalized syntax
comparison.
- Before merge integration, the completed stack passed 525 targeted
cases, repository-wide typecheck and build. Its stronger rotating-token
fixture is included.
- After replaying onto master, the integrated stack passes 601 cases
across nine suites, including Braintrust lifecycle and connector-policy
coverage. Server typecheck and module-boundary checks pass. All 157
moved bodies still match the current master versions.
- The earlier local full-suite run was interrupted after 79 minutes and
remains unverified. All current-head CI gates pass on
`a21595f20809a514b7619d8a8748c56f8e546e2f`, including the full sharded
test matrix, browser suites, typecheck, build and canary dry run.
Greptile is 5/5 with no open review threads after merge integration.

## Risks

- Discovery must keep its current grant and session behavior;
publication must retain its locks and quarantine rules. Existing
authority, schema-drift and publication-race tests exercise these paths.
- Aggregator account observations must not become authorization. Lease,
freshness, failure and credential-fingerprint behavior stay unchanged.
- Live transport options must not be snapshotted. The HTTP owner retains
the same options object.
- No schema, public API, access-policy or scheduling change is intended.

## Model Used

OpenAI Codex, GPT-6 family, with repository tools and code execution.
The exact serving model ID 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>
2026-10-21 22:38:59 -05:00

76 lines
3.7 KiB
TypeScript

import { singleFlight } from "../single-flight.js";
import type { ActorInfo } from "../types.js";
import { descriptorHash } from "../../tool-catalog-hash.js";
import { DAYTONA_TOOLS, daytonaRisk, isDaytonaConnection, isDaytonaToolName } from "../../daytona.js";
import { desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { toolCatalogEntries, toolConnections } from "@paperclipai/db";
import type { ToolCatalogEntry } from "@paperclipai/shared";
import type { RefreshConnectionCatalog } from "./contracts.js";
export function createToolCatalogReader(db: Db, options: { catalogCacheTtlMs?: number }, now: () => Date, getConnectionRow: (idOrUid: string, companyId?: string) => Promise<typeof toolConnections.$inferSelect>, refreshCatalog: RefreshConnectionCatalog, toCatalogEntryForConnection: (row: typeof toolCatalogEntries.$inferSelect, connection: typeof toolConnections.$inferSelect) => ToolCatalogEntry) {
const catalogRefreshFlights = new Map<string, Promise<unknown>>();
const catalogCacheTtlMs = Math.max(
0,
options.catalogCacheTtlMs ?? 15 * 60 * 1000,
);
return {
listCatalog: async (
connectionId: string,
companyId?: string,
actor?: ActorInfo,
): Promise<ToolCatalogEntry[]> => {
const connection = await getConnectionRow(connectionId, companyId);
let rows = await db
.select()
.from(toolCatalogEntries)
.where(eq(toolCatalogEntries.connectionId, connection.id))
.orderBy(desc(toolCatalogEntries.updatedAt));
// Native provider catalogs change with the deployed code. Upgrade saved
// Daytona connections too; their first small catalog must not survive
// merely because REST discovery has no remote MCP cache timer.
const nativeDaytona = isDaytonaConnection(connection);
const rowsByName = new Map(rows.map((row) => [row.toolName, row]));
const daytonaCatalogChanged = nativeDaytona && connection.status === "active" && (
rows.some((row) => row.status !== "removed" && !isDaytonaToolName(row.toolName))
|| DAYTONA_TOOLS.some((tool) => {
const existing = rowsByName.get(tool.name);
return !existing || existing.versionHash !== descriptorHash(tool, daytonaRisk(tool.name));
})
);
const cacheExpired = daytonaCatalogChanged || (
connection.transport === "mcp_remote" &&
connection.status !== "archived" &&
(rows.length === 0 ||
!connection.lastCatalogRefreshAt ||
connection.lastCatalogRefreshAt.getTime() <=
now().getTime() - catalogCacheTtlMs));
if (cacheExpired) {
try {
await singleFlight(catalogRefreshFlights, connection.id, () =>
refreshCatalog(connection.id, nativeDaytona && actor ? actor : {
actorType: "system",
actorId: "tool_catalog_cache",
}),
);
rows = await db
.select()
.from(toolCatalogEntries)
.where(eq(toolCatalogEntries.connectionId, connection.id))
.orderBy(desc(toolCatalogEntries.updatedAt));
} catch (error) {
// A stale catalog remains useful when the remote server is temporarily
// unavailable. Empty caches still fail so callers never mistake “no
// actions discovered” for a successful lookup.
if (rows.length === 0) throw error;
}
}
// Even failed discovery must not keep retired native tools visible.
// Their database rows remain available to historical invocation records.
return rows
.filter((row) => !nativeDaytona || (row.status !== "removed" && isDaytonaToolName(row.toolName)))
.map((row) => toCatalogEntryForConnection(row, connection));
}
};
}