mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
## 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>
76 lines
3.7 KiB
TypeScript
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));
|
|
}
|
|
};
|
|
}
|