Files
PaperClipAI/server/src/services/tool-access/assignment-locks.ts
T
DottaandPaperclip 233753e577 refactor(tools): isolate profile ownership (#17155)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Tool profiles select the actions each agent can use.
> - Profile reads, review, mutation and assignment resolution shared a
large OAuth and connection service.
> - Those paths have their own company checks, lock order and database
projections.
> - This pull request gives profiles explicit owners while preserving
the existing transactions and public API.
> - The benefit is a smaller coordinator and a reviewable access
boundary.

## Linked Issues or Issue Description

Depends on #17147. Stack: #17144 → #17146 → #17147 → this PR. These PRs
remain open.

**What existing behavior does this improve?**

Maintenance of tool profile management and effective agent assignments.

**Current behavior**

The service entry point contains 19,293 lines. Profile behavior is
spread across its closure and returned object.

**Proposed behavior**

Profile management accepts only a database and clock. Assignment
resolution accepts three named capabilities. Projection and shared lock
functions have explicit homes. Public methods and responses remain
unchanged.

**Reason and benefit**

Reviewers can follow profile transactions, lightweight reads and
precedence without navigating OAuth setup. The entry point falls to
17,752 lines, a reduction of 1,541 lines in this PR.

## What Changed

- Extract profile management, projections and effective assignment
resolution into three modules. Their sizes are 886, 268 and 216 lines.
- Move shared actor/transaction types, identifiers and assignment locks
into named modules. Preserve the public lock export.
- Keep all database reads, transaction boundaries, lock order and method
signatures. Add no cache or background work.
- Record the next ownership cuts toward an entry point below 5,000
lines.

## Verification

- Before and after: 466 cases in `tool-access-service.test.ts` and 33
cases across profile entry validation, effective assignments and catalog
publication. All 499 cases pass with one database worker.
- Server TypeScript check and `node scripts/check-module-boundaries.mjs`
pass. `git diff --check` passes.
- Normalized syntax comparison finds all 329 pre-existing
function/method bodies unchanged. Wiring passes the unchanged public
service tests.
- The previous stack passed full typecheck and build. Its full local
test run was interrupted and remains unverified; its complete CI matrix
passed. This PR will record its own current-head CI result below.

- Current-head CI is green. Current review: Greptile is 5/5 at
`0f5c85905e`, with no inline review threads.
- CI failure preserved: [general server shard
1](https://github.com/paperclipai/paperclip/actions/runs/39704932041/job/119582710174)
failed the unchanged workspace-runtime test that replaces an unhealthy
local process. The replacement refused connections for its original
10-second readiness budget. The fixture and runtime service are
unchanged and do not import tool-access. One isolated run passed without
changes or timeout increases. The failure log is preserved; the cause is
not reproduced. Only that failed job was retried; it passed at the same
head without code changes. The original failure remains recorded above.

## Risks

- Moving profile mutations could change transaction clients or lock
order. Both are unchanged and the existing database tests cover
publication and assignment behavior.
- Effective assignment resolution still uses the same
installed-connection loader and full catalog projection. The lightweight
path still omits large schema and metadata fields.
- No schema, public API or access policy change is intended. Existing
tests provide characterization coverage; no new test mirrors the
extraction.

## 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:21:42 -05:00

47 lines
3.6 KiB
TypeScript

import { and, eq, inArray, sql } from "drizzle-orm";
import { agents, toolApplications, toolCatalogEntries, toolConnections, toolPolicies, toolProfiles } from "@paperclipai/db";
import type { CreateToolProfileEntryForProfile } from "@paperclipai/shared";
import type { ToolAccessMutationDb } from "./types.js";
export async function lockManagedAppPolicies(client: ToolAccessMutationDb, companyId: string, connectionId: string) {
await client.select({ id: toolPolicies.id }).from(toolPolicies).where(and(
eq(toolPolicies.companyId, companyId), eq(toolPolicies.policyType, "require_approval"),
sql`${toolPolicies.config}->>'source' = 'app_gallery_finish'`,
sql`${toolPolicies.config}->>'connectionId' = ${connectionId}`,
)).orderBy(toolPolicies.id).for("no key update");
}
export async function lockToolAssignmentAgents(client: ToolAccessMutationDb, companyId: string, targets: ReadonlyArray<{ targetType: string; targetId: string }>) {
const allAgents = targets.some(target => target.targetType === "company" && target.targetId === companyId);
const ids = targets.filter(target => target.targetType === "agent").map(target => target.targetId);
if (!allAgents && !ids.length) return;
await client.select({ id: agents.id }).from(agents)
.where(and(eq(agents.companyId, companyId), sql`${agents.status} NOT IN ('pending_approval', 'terminated')`,
allAgents ? undefined : inArray(agents.id, ids)))
.orderBy(agents.id).for("no key update");
}
export async function lockToolConnectionRuntimeInputs(client: ToolAccessMutationDb, companyId: string, connectionId: string) {
await client.select({ id: toolApplications.id }).from(toolApplications)
.innerJoin(toolConnections, and(eq(toolConnections.applicationId, toolApplications.id), eq(toolConnections.companyId, toolApplications.companyId)))
.where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId)))
.for("update", { of: toolApplications });
await client.select({ id: toolConnections.id }).from(toolConnections)
.where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))).for("update");
await client.select({ id: toolProfiles.id }).from(toolProfiles)
.where(and(eq(toolProfiles.companyId, companyId), eq(toolProfiles.profileKey, `app:${connectionId}`)))
.for("no key update");
await lockManagedAppPolicies(client, companyId, connectionId);
}
export async function lockProfileEntryReferences(client: ToolAccessMutationDb, companyId: string, entries: Pick<CreateToolProfileEntryForProfile, "applicationId" | "connectionId" | "catalogEntryId">[]) {
const applicationIds = [...new Set(entries.flatMap(entry => entry.applicationId ? [entry.applicationId] : []))];
const connectionIds = [...new Set(entries.flatMap(entry => entry.connectionId ? [entry.connectionId] : []))];
const catalogIds = [...new Set(entries.flatMap(entry => entry.catalogEntryId ? [entry.catalogEntryId] : []))];
if (applicationIds.length) await client.select({ id: toolApplications.id }).from(toolApplications)
.where(and(eq(toolApplications.companyId, companyId), inArray(toolApplications.id, applicationIds))).orderBy(toolApplications.id).for("key share");
if (connectionIds.length) await client.select({ id: toolConnections.id }).from(toolConnections)
.where(and(eq(toolConnections.companyId, companyId), inArray(toolConnections.id, connectionIds))).orderBy(toolConnections.id).for("key share");
if (catalogIds.length) await client.select({ id: toolCatalogEntries.id }).from(toolCatalogEntries)
.where(and(eq(toolCatalogEntries.companyId, companyId), inArray(toolCatalogEntries.id, catalogIds))).orderBy(toolCatalogEntries.id).for("key share");
}