mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Advanced YAML editor reads shared tool profiles through their owning service. > - The editor must preserve hidden settings and reject real concurrent changes. > - Batch-created entries share a creation time. Queries sorted only by that time can return the same entries in a different order. > - The editor can then report a false concurrent tool change before it sends a profile binding request. > - This pull request adds an ID tie-breaker to profile entry reads. > - The benefit is a stable saved configuration without removing the concurrent-change guard. ## Linked Issues or Issue Description Refs #18356. Related: #18168. **What happened?** The compact YAML browser case failed at its second save. Save stayed enabled and the profile binding write did not occur. The original CI artifact has no response bodies. It does not establish the exact entry order from that run. A controlled browser test reproduced this failure. It changed only the order of 30 equal-time entries in the second preflight response. The client reported `tools changed while you were editing. Reload saved before saving.` No binding request followed. A separate first-save response barrier showed that the pending-save guard worked. **Expected behavior** Reads of unchanged entries must have a stable order. Real entry changes must still trigger the existing configuration checks. Entry order does not set policy precedence; exclusions still win over matching includes. **Steps to reproduce** Create a profile with entries that share one creation time. Read the profile, change the physical row layout in a disposable PostgreSQL database, and read it again. The old service has no tie-breaker for these entries. The new regression fails against that service. ## What Changed - Sort list and detail entry reads by creation time, then ID, in the profile service. These reads supply saved configuration snapshots. Other reads retain their existing behavior. - Add one real PostgreSQL regression. It changes heap layout in both directions, checks stable list and detail values, preserves chronological ordering, checks company boundaries, and checks include/exclude results and a real entry change. - Include the exact test-only recovery prerequisite from commit `36c8d9ddd76d0b7ddb28a9a6c674d9e538f6df88`. Its file blob is unchanged. It removes a one-sweep fixture assumption while keeping bounded recovery assertions. No document UI or journey code is imported. ## Verification - Base: one frozen public master snapshot, `8c9dd1e4445e58cc9ca08076dfe9c0b27038d6ee`. The affected service, schema, projection, resolver, and save guard match the failed CI merge `94d98d5c75c8f331aad4bfab2111ee587b0c63fe`. - RED: the new real PostgreSQL test fails on the old service. Both physical layouts were verified before the ordering assertion failed. - GREEN: `cd server && pnpm exec vitest run src/__tests__/tool-profile-entry-order.test.ts src/__tests__/tool-profile-entry-validation.test.ts` — 18 tests pass with real PostgreSQL 17. - GREEN: `cd ui && pnpm exec vitest run src/lib/agent-configuration/save.test.ts src/lib/agent-configuration-yaml.test.ts --maxWorkers 1` — 51 tests pass. - GREEN: one focused Chromium treatment of the original compact YAML case passes. The treatment changes the actual disposable database heap after the first save. It does not transform HTTP responses. The 30 equal-time entries stay identical through the API, the second bind returns 201, and the original hidden-setting and disabled-Save assertions pass. - `node scripts/check-maps.mjs` and `git diff --check` pass. - Local tests use Node 24, a macOS source server, Chromium, and an isolated loopback PostgreSQL 17 container. The server and container are stopped. Private traces and control source are retained outside Git. These controls do not prove a deployed Cloud result. - A review follow-up removes two unnecessary query changes. Fresh real PostgreSQL verification still passes all 18 tests. The list/detail queries and browser path are unchanged from the passing heap-reorder treatment. - Prior head `59edcade203d6a05923955f5a0118e62c7e549c5` had a separate browser failure in run `41169427774`: `disconnect-write-recovery.spec.ts:204` kept the original title after a mocked session lookup failure. Its relevant test and UI source are unchanged from the base. The cause is unresolved. The two removed query changes do not repair it. The original log and artifact are retained; no unchanged job retry was issued. On the current head, this case passes in 14.3 seconds. The compact YAML case also passes in 11.2 seconds. The later success does not establish the prior failure cause. - Final exact head: `81c584aeac116b9d7a126b55dc99d5f6c70c195f`. [CI run 41169978784](https://github.com/paperclipai/paperclip/actions/runs/41169978784) completes with success: all 47 workflow jobs are terminal, and all 57 PR check/status entries are green or accepted neutral/skipped states. Full typecheck, build, Runner checks, security checks, and `ci / verify` pass. They were not repeated as broad local commands because the shared host is under load. - The new PostgreSQL regression also passes in Linux CI in 911 ms. Server shard 12 has 1,839 passing tests. Browser shard 3 has 45 passing tests, including the original compact YAML case. - Fresh Greptile and Paperclip reviews both give this exact head 5/5 with no findings. All review threads are resolved. The branch is mergeable and clean. ## Risks - Entries with the same creation time now use ID order. Their prior relative order was undefined. Entries with different creation times keep their existing order. - The query still sorts the same rows and adds one key to the sort. It adds no query, polling, schema change, or migration. - The configuration equality and concurrent-change checks are unchanged. This fix does not ignore real tool changes. - The original CI response order is unknown. The regression proves a permitted ordering defect and the matching failure mechanism, not the unique historical cause. ## Model Used - OpenAI Codex, based on GPT-6, with reasoning, source inspection, terminal tools, and browser test 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>
900 lines
33 KiB
TypeScript
900 lines
33 KiB
TypeScript
import type { ConnectionManagementContext } from "../../connection-management.js";
|
|
import { connectionActorUserId } from "../actor.js";
|
|
import { assertProfileEntryInputs } from "../../tool-profile-entry-validation.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { agents, issues, projects, routines, toolApplications, toolCatalogEntries, toolConnections, toolMcpGateways, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db";
|
|
import type { CreateToolProfileBindingForProfile, CreateToolProfileEntryForProfile, CreateToolProfileWithEntries, DeleteToolProfile, DuplicateToolProfile, ToolProfile, ToolProfileBinding, ToolProfileEntry, ToolProfileNewToolReviewItem, ToolProfileNewToolsReview, ToolProfileNewToolsReviewResult, ToolProfileSummary, ToolProfileWithDetails, ReviewToolProfileNewTools, UpdateToolProfileEntry, UpdateToolProfileWithEntries, UnbindToolProfileBinding } from "@paperclipai/shared";
|
|
import { badRequest, conflict, notFound, unprocessable } from "../../../errors.js";
|
|
import type { ActorInfo, ToolAccessMutationDb } from "../types.js";
|
|
import { lockProfileEntryReferences, lockToolAssignmentAgents } from "../assignment-locks.js";
|
|
import { normalizeKey } from "../identifiers.js";
|
|
import { toProfile, toProfileEntry, toProfileBinding, profileCatalogColumns, profileReviewCatalogColumns, pendingNewToolsForProfile, buildProfileDetails } from "./projection.js";
|
|
|
|
export function createToolProfileService(db: Db, now: () => Date) {
|
|
async function getProfileRow(profileId: string, companyId?: string, dbClient: ToolAccessMutationDb = db) {
|
|
const where = companyId
|
|
? and(
|
|
eq(toolProfiles.id, profileId),
|
|
eq(toolProfiles.companyId, companyId),
|
|
)
|
|
: eq(toolProfiles.id, profileId);
|
|
const [row] = await dbClient.select().from(toolProfiles).where(where);
|
|
if (!row) throw notFound("Tool profile not found");
|
|
return row;
|
|
}
|
|
|
|
async function profileDetails(
|
|
profileId: string,
|
|
companyId?: string,
|
|
dbClient: ToolAccessMutationDb = db,
|
|
): Promise<ToolProfileWithDetails> {
|
|
const profile = await getProfileRow(profileId, companyId, dbClient);
|
|
const [
|
|
entries,
|
|
bindings,
|
|
catalog,
|
|
companyAgents,
|
|
applications,
|
|
connections,
|
|
] = await Promise.all([
|
|
dbClient
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, profile.companyId),
|
|
eq(toolProfileEntries.profileId, profile.id),
|
|
),
|
|
)
|
|
.orderBy(asc(toolProfileEntries.createdAt), asc(toolProfileEntries.id)),
|
|
dbClient
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, profile.companyId),
|
|
eq(toolProfileBindings.profileId, profile.id),
|
|
),
|
|
)
|
|
.orderBy(
|
|
asc(toolProfileBindings.priority),
|
|
asc(toolProfileBindings.createdAt),
|
|
),
|
|
dbClient
|
|
.select(profileCatalogColumns)
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, profile.companyId),
|
|
eq(toolCatalogEntries.status, "active"),
|
|
),
|
|
),
|
|
dbClient
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(eq(agents.companyId, profile.companyId)),
|
|
dbClient
|
|
.select({ id: toolApplications.id, name: toolApplications.name })
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.companyId, profile.companyId)),
|
|
dbClient
|
|
.select({ id: toolConnections.id, name: toolConnections.name })
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.companyId, profile.companyId)),
|
|
]);
|
|
return buildProfileDetails({
|
|
profile,
|
|
entries,
|
|
bindings,
|
|
catalog,
|
|
agentIds: companyAgents.map((agent) => agent.id),
|
|
applicationsById: new Map(
|
|
applications.map((application) => [application.id, application]),
|
|
),
|
|
connectionsById: new Map(
|
|
connections.map((connection) => [connection.id, connection]),
|
|
),
|
|
});
|
|
}
|
|
|
|
async function listProfileNewTools(
|
|
profileId: string,
|
|
companyId?: string,
|
|
): Promise<ToolProfileNewToolsReview> {
|
|
const profile = await getProfileRow(profileId, companyId);
|
|
const [entries, catalog, applications, connections] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, profile.companyId),
|
|
eq(toolProfileEntries.profileId, profile.id),
|
|
),
|
|
)
|
|
.orderBy(asc(toolProfileEntries.createdAt)),
|
|
db
|
|
.select(profileReviewCatalogColumns)
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, profile.companyId),
|
|
eq(toolCatalogEntries.status, "active"),
|
|
),
|
|
)
|
|
.orderBy(asc(toolCatalogEntries.toolName)),
|
|
db
|
|
.select({ id: toolApplications.id, name: toolApplications.name })
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.companyId, profile.companyId)),
|
|
db
|
|
.select({ id: toolConnections.id, name: toolConnections.name })
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.companyId, profile.companyId)),
|
|
]);
|
|
const tools = pendingNewToolsForProfile({
|
|
profile,
|
|
entries,
|
|
catalog,
|
|
applicationsById: new Map(
|
|
applications.map((application) => [application.id, application]),
|
|
),
|
|
connectionsById: new Map(
|
|
connections.map((connection) => [connection.id, connection]),
|
|
),
|
|
});
|
|
return {
|
|
profileId: profile.id,
|
|
reviewedAt: profile.newToolsReviewedAt,
|
|
pendingCount: tools.length,
|
|
tools,
|
|
};
|
|
}
|
|
|
|
async function reviewProfileNewTools(
|
|
profileId: string,
|
|
input: ReviewToolProfileNewTools,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolProfileNewToolsReviewResult> {
|
|
await actor?.managementContext?.reauthorize();
|
|
const profile = await getProfileRow(profileId);
|
|
const review = await listProfileNewTools(profile.id, profile.companyId);
|
|
if (review.tools.length === 0)
|
|
throw badRequest("No new tools are pending review for this profile");
|
|
|
|
const decisionIds = input.decisions.map(
|
|
(decision) => decision.catalogEntryId,
|
|
);
|
|
if (new Set(decisionIds).size !== decisionIds.length) {
|
|
throw badRequest(
|
|
"New-tools review decisions must not contain duplicate catalogEntryId values",
|
|
);
|
|
}
|
|
const pendingIds = new Set(review.tools.map((tool) => tool.catalogEntryId));
|
|
if (
|
|
decisionIds.length !== pendingIds.size ||
|
|
decisionIds.some((id) => !pendingIds.has(id))
|
|
) {
|
|
throw badRequest(
|
|
"New-tools review decisions must cover every currently pending tool exactly once",
|
|
);
|
|
}
|
|
|
|
const toolById = new Map(
|
|
review.tools.map((tool) => [tool.catalogEntryId, tool]),
|
|
);
|
|
const allowTools = input.decisions
|
|
.filter((decision) => decision.decision === "allow")
|
|
.map((decision) => toolById.get(decision.catalogEntryId))
|
|
.filter(Boolean) as ToolProfileNewToolReviewItem[];
|
|
const nowAt = now();
|
|
let createdEntries: ToolProfileEntry[] = [];
|
|
if (allowTools.length > 0) {
|
|
const rows = await db
|
|
.insert(toolProfileEntries)
|
|
.values(
|
|
allowTools.map((tool) => ({
|
|
companyId: profile.companyId,
|
|
profileId: profile.id,
|
|
selectorType: "catalog_entry" as const,
|
|
effect: "include" as const,
|
|
applicationId: tool.applicationId,
|
|
connectionId: tool.connectionId,
|
|
catalogEntryId: tool.catalogEntryId,
|
|
})),
|
|
)
|
|
.returning();
|
|
createdEntries = rows.map(toProfileEntry);
|
|
}
|
|
|
|
await db
|
|
.update(toolCatalogEntries)
|
|
.set({
|
|
reviewedAt: nowAt,
|
|
reviewedByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
reviewedByUserId:
|
|
connectionActorUserId(actor),
|
|
updatedAt: nowAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, profile.companyId),
|
|
inArray(toolCatalogEntries.id, decisionIds),
|
|
),
|
|
);
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ newToolsReviewedAt: nowAt, updatedAt: nowAt })
|
|
.where(eq(toolProfiles.id, profile.id));
|
|
|
|
return {
|
|
profile: await profileDetails(profile.id, profile.companyId),
|
|
reviewedAt: nowAt,
|
|
allowedCount: allowTools.length,
|
|
keptBlockedCount: input.decisions.length - allowTools.length,
|
|
entriesCreated: createdEntries,
|
|
reviewedCatalogEntryIds: decisionIds,
|
|
};
|
|
}
|
|
|
|
async function createProfileEntries(
|
|
companyId: string,
|
|
profileId: string,
|
|
entries: CreateToolProfileEntryForProfile[],
|
|
dbClient: ToolAccessMutationDb = db,
|
|
) {
|
|
await assertProfileEntryInputs(dbClient, companyId, entries);
|
|
if (entries.length === 0) return;
|
|
await dbClient.insert(toolProfileEntries).values(
|
|
entries.map((entry) => ({
|
|
companyId,
|
|
profileId,
|
|
selectorType: entry.selectorType,
|
|
effect: entry.effect ?? "include",
|
|
applicationId: entry.applicationId ?? null,
|
|
connectionId: entry.connectionId ?? null,
|
|
catalogEntryId: entry.catalogEntryId ?? null,
|
|
toolName: entry.toolName ?? null,
|
|
riskLevel: entry.riskLevel ?? null,
|
|
conditions: entry.conditions ?? null,
|
|
})),
|
|
);
|
|
}
|
|
|
|
async function replaceProfileEntries(
|
|
companyId: string,
|
|
profileId: string,
|
|
entries: CreateToolProfileEntryForProfile[],
|
|
dbClient: ToolAccessMutationDb = db,
|
|
) {
|
|
await assertProfileEntryInputs(dbClient, companyId, entries);
|
|
await dbClient
|
|
.delete(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, companyId),
|
|
eq(toolProfileEntries.profileId, profileId),
|
|
),
|
|
);
|
|
await createProfileEntries(companyId, profileId, entries, dbClient);
|
|
}
|
|
|
|
async function assertTargetExists(
|
|
companyId: string,
|
|
targetType: CreateToolProfileBindingForProfile["targetType"],
|
|
targetId: string,
|
|
dbClient: ToolAccessMutationDb = db,
|
|
) {
|
|
if (targetType === "company") {
|
|
if (targetId !== companyId)
|
|
throw unprocessable(
|
|
"Company profile bindings must target the same company id",
|
|
);
|
|
return;
|
|
}
|
|
if (targetType === "agent") {
|
|
const [row] = await dbClient
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(and(eq(agents.id, targetId), eq(agents.companyId, companyId)));
|
|
if (!row)
|
|
throw unprocessable(
|
|
"Tool profile agent binding target must belong to the same company",
|
|
);
|
|
return;
|
|
}
|
|
if (targetType === "project") {
|
|
const [row] = await dbClient
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(
|
|
and(eq(projects.id, targetId), eq(projects.companyId, companyId)),
|
|
);
|
|
if (!row)
|
|
throw unprocessable(
|
|
"Tool profile project binding target must belong to the same company",
|
|
);
|
|
return;
|
|
}
|
|
if (targetType === "routine") {
|
|
const [row] = await dbClient
|
|
.select({ id: routines.id })
|
|
.from(routines)
|
|
.where(
|
|
and(eq(routines.id, targetId), eq(routines.companyId, companyId)),
|
|
);
|
|
if (!row)
|
|
throw unprocessable(
|
|
"Tool profile routine binding target must belong to the same company",
|
|
);
|
|
return;
|
|
}
|
|
if (targetType === "issue") {
|
|
const [row] = await dbClient
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(and(eq(issues.id, targetId), eq(issues.companyId, companyId)));
|
|
if (!row)
|
|
throw unprocessable(
|
|
"Tool profile issue binding target must belong to the same company",
|
|
);
|
|
return;
|
|
}
|
|
if (targetType === "gateway") {
|
|
const [row] = await dbClient
|
|
.select({ id: toolMcpGateways.id })
|
|
.from(toolMcpGateways)
|
|
.where(
|
|
and(
|
|
eq(toolMcpGateways.id, targetId),
|
|
eq(toolMcpGateways.companyId, companyId),
|
|
),
|
|
);
|
|
if (!row)
|
|
throw unprocessable(
|
|
"Tool profile gateway binding target must belong to the same company",
|
|
);
|
|
}
|
|
}
|
|
return {
|
|
listProfiles: async (
|
|
companyId: string,
|
|
): Promise<ToolProfileWithDetails[]> => {
|
|
const profiles = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.companyId, companyId))
|
|
.orderBy(desc(toolProfiles.updatedAt));
|
|
if (profiles.length === 0) return [];
|
|
const profileIds = profiles.map((profile) => profile.id);
|
|
const [
|
|
entries,
|
|
bindings,
|
|
catalog,
|
|
companyAgents,
|
|
applications,
|
|
connections,
|
|
] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, companyId),
|
|
inArray(toolProfileEntries.profileId, profileIds),
|
|
),
|
|
)
|
|
.orderBy(asc(toolProfileEntries.createdAt), asc(toolProfileEntries.id)),
|
|
db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, companyId),
|
|
inArray(toolProfileBindings.profileId, profileIds),
|
|
),
|
|
)
|
|
.orderBy(
|
|
asc(toolProfileBindings.priority),
|
|
asc(toolProfileBindings.createdAt),
|
|
),
|
|
db
|
|
.select(profileCatalogColumns)
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
eq(toolCatalogEntries.status, "active"),
|
|
),
|
|
),
|
|
db
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(eq(agents.companyId, companyId)),
|
|
db
|
|
.select({ id: toolApplications.id, name: toolApplications.name })
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.companyId, companyId)),
|
|
db
|
|
.select({ id: toolConnections.id, name: toolConnections.name })
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.companyId, companyId)),
|
|
]);
|
|
const entriesByProfile = new Map<
|
|
string,
|
|
Array<typeof toolProfileEntries.$inferSelect>
|
|
>();
|
|
const bindingsByProfile = new Map<
|
|
string,
|
|
Array<typeof toolProfileBindings.$inferSelect>
|
|
>();
|
|
for (const entry of entries) {
|
|
const list = entriesByProfile.get(entry.profileId) ?? [];
|
|
list.push(entry);
|
|
entriesByProfile.set(entry.profileId, list);
|
|
}
|
|
for (const binding of bindings) {
|
|
const list = bindingsByProfile.get(binding.profileId) ?? [];
|
|
list.push(binding);
|
|
bindingsByProfile.set(binding.profileId, list);
|
|
}
|
|
const agentIds = companyAgents.map((agent) => agent.id);
|
|
const applicationsById = new Map(
|
|
applications.map((application) => [application.id, application]),
|
|
);
|
|
const connectionsById = new Map(
|
|
connections.map((connection) => [connection.id, connection]),
|
|
);
|
|
return profiles.map((profile) =>
|
|
buildProfileDetails({
|
|
profile,
|
|
entries: entriesByProfile.get(profile.id) ?? [],
|
|
bindings: bindingsByProfile.get(profile.id) ?? [],
|
|
catalog,
|
|
agentIds,
|
|
applicationsById,
|
|
connectionsById,
|
|
}),
|
|
);
|
|
},
|
|
|
|
createProfile: async (
|
|
companyId: string,
|
|
input: CreateToolProfileWithEntries, managementContext?: ConnectionManagementContext,
|
|
): Promise<ToolProfileWithDetails> => {
|
|
await managementContext?.reauthorize();
|
|
await assertProfileEntryInputs(db, companyId, input.entries ?? []);
|
|
const [row] = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId,
|
|
profileKey: input.profileKey,
|
|
name: input.name,
|
|
description: input.description ?? null,
|
|
status: input.status ?? "active",
|
|
defaultAction: input.defaultAction ?? "deny",
|
|
metadata: input.metadata ?? {},
|
|
})
|
|
.returning();
|
|
await createProfileEntries(companyId, row.id, input.entries ?? []);
|
|
return profileDetails(row.id, companyId);
|
|
},
|
|
|
|
getProfile: profileDetails,
|
|
|
|
listProfileNewTools,
|
|
|
|
reviewProfileNewTools,
|
|
|
|
updateProfile: async (
|
|
profileId: string,
|
|
input: UpdateToolProfileWithEntries, managementContext?: ConnectionManagementContext,
|
|
): Promise<ToolProfileWithDetails> => {
|
|
await managementContext?.reauthorize();
|
|
const existing = await getProfileRow(profileId);
|
|
if (input.entries) {
|
|
await assertProfileEntryInputs(db, existing.companyId, input.entries);
|
|
}
|
|
const referencesChanged = new Error("Tool profile references changed");
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
try {
|
|
await db.transaction(async tx => {
|
|
const db = tx as unknown as Db;
|
|
const readReferences = () => tx.select({ applicationId: toolProfileEntries.applicationId, connectionId: toolProfileEntries.connectionId, catalogEntryId: toolProfileEntries.catalogEntryId })
|
|
.from(toolProfileEntries).where(and(eq(toolProfileEntries.companyId, existing.companyId), eq(toolProfileEntries.profileId, profileId)))
|
|
.orderBy(toolProfileEntries.id);
|
|
const oldReferences = input.entries ? await readReferences() : [];
|
|
await lockProfileEntryReferences(tx, existing.companyId, [...oldReferences, ...(input.entries ?? [])]);
|
|
const [current] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update");
|
|
if (!current) throw notFound("Tool profile not found");
|
|
if (input.entries && JSON.stringify(await readReferences()) !== JSON.stringify(oldReferences)) throw referencesChanged;
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({
|
|
profileKey: input.profileKey ?? current.profileKey,
|
|
name: input.name ?? current.name,
|
|
description: input.description ?? current.description,
|
|
status: input.status ?? current.status,
|
|
defaultAction: input.defaultAction ?? current.defaultAction,
|
|
metadata: input.metadata ?? current.metadata,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolProfiles.id, profileId));
|
|
if (input.entries) {
|
|
await replaceProfileEntries(
|
|
existing.companyId,
|
|
profileId,
|
|
input.entries,
|
|
tx,
|
|
);
|
|
}
|
|
});
|
|
break;
|
|
} catch (error) {
|
|
if (error !== referencesChanged) throw error;
|
|
if (attempt >= 2) throw conflict("Tool profile entries changed during replacement", { retryable: true });
|
|
}
|
|
}
|
|
return profileDetails(profileId, existing.companyId);
|
|
},
|
|
|
|
duplicateProfile: async (
|
|
profileId: string,
|
|
input: DuplicateToolProfile, managementContext?: ConnectionManagementContext,
|
|
): Promise<ToolProfileWithDetails> => {
|
|
await managementContext?.reauthorize();
|
|
const existing = await getProfileRow(profileId);
|
|
const [entries, bindings] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, existing.companyId),
|
|
eq(toolProfileEntries.profileId, existing.id),
|
|
),
|
|
)
|
|
.orderBy(asc(toolProfileEntries.createdAt)),
|
|
db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, existing.companyId),
|
|
eq(toolProfileBindings.profileId, existing.id),
|
|
),
|
|
)
|
|
.orderBy(
|
|
asc(toolProfileBindings.priority),
|
|
asc(toolProfileBindings.createdAt),
|
|
),
|
|
]);
|
|
const [created] = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId: existing.companyId,
|
|
profileKey: normalizeKey(`${input.name}-${randomUUID().slice(0, 8)}`),
|
|
name: input.name,
|
|
description: existing.description,
|
|
status: "active",
|
|
defaultAction: existing.defaultAction,
|
|
newToolsReviewedAt: existing.newToolsReviewedAt,
|
|
metadata: existing.metadata ?? {},
|
|
})
|
|
.returning();
|
|
if (entries.length > 0) {
|
|
await db.insert(toolProfileEntries).values(
|
|
entries.map((entry) => ({
|
|
companyId: entry.companyId,
|
|
profileId: created.id,
|
|
selectorType: entry.selectorType,
|
|
effect: entry.effect,
|
|
applicationId: entry.applicationId,
|
|
connectionId: entry.connectionId,
|
|
catalogEntryId: entry.catalogEntryId,
|
|
toolName: entry.toolName,
|
|
riskLevel: entry.riskLevel,
|
|
conditions: entry.conditions,
|
|
})),
|
|
);
|
|
}
|
|
if (input.includeAssignments && bindings.length > 0) {
|
|
await db.insert(toolProfileBindings).values(
|
|
bindings.map((binding) => ({
|
|
companyId: binding.companyId,
|
|
profileId: created.id,
|
|
targetType: binding.targetType,
|
|
targetId: binding.targetId,
|
|
priority: binding.priority,
|
|
metadata: binding.metadata ?? {},
|
|
createdByAgentId: binding.createdByAgentId,
|
|
createdByUserId: binding.createdByUserId,
|
|
})),
|
|
);
|
|
}
|
|
return profileDetails(created.id, existing.companyId);
|
|
},
|
|
|
|
deleteProfile: async (
|
|
profileId: string,
|
|
input: DeleteToolProfile, managementContext?: ConnectionManagementContext,
|
|
): Promise<{
|
|
profile: ToolProfile;
|
|
summary: ToolProfileSummary;
|
|
reassignedToProfileId: string | null;
|
|
reassignedBindingCount: number;
|
|
}> => db.transaction(async tx => {
|
|
await managementContext?.reauthorize();
|
|
const existing = await getProfileRow(profileId, undefined, tx);
|
|
const db = tx as unknown as Db;
|
|
await tx.select({ id: toolProfiles.id }).from(toolProfiles)
|
|
.where(and(eq(toolProfiles.companyId, existing.companyId), inArray(toolProfiles.id, [profileId, ...(input.reassignToProfileId ? [input.reassignToProfileId] : [])])))
|
|
.orderBy(toolProfiles.id).for("update");
|
|
await lockToolAssignmentAgents(tx, existing.companyId, [{ targetType: "company", targetId: existing.companyId }]);
|
|
if (input.force && input.reassignToProfileId) {
|
|
throw badRequest(
|
|
"Use either force or reassignToProfileId when deleting a tool profile, not both",
|
|
);
|
|
}
|
|
const details = await profileDetails(existing.id, existing.companyId, tx);
|
|
if (
|
|
details.summary.isCompanyDefault &&
|
|
!input.force &&
|
|
!input.reassignToProfileId
|
|
) {
|
|
throw unprocessable(
|
|
"Cannot delete the company default tool profile. Reassign the default profile or pass force=true to delete it.",
|
|
{ summary: details.summary },
|
|
);
|
|
}
|
|
|
|
let reassignedBindingCount = 0;
|
|
if (input.reassignToProfileId) {
|
|
if (input.reassignToProfileId === existing.id) {
|
|
throw badRequest(
|
|
"reassignToProfileId must reference a different tool profile",
|
|
);
|
|
}
|
|
const target = await getProfileRow(
|
|
input.reassignToProfileId,
|
|
existing.companyId,
|
|
tx,
|
|
);
|
|
if (target.status !== "active") {
|
|
throw unprocessable(
|
|
"Tool profile assignments can only be reassigned to an active profile",
|
|
);
|
|
}
|
|
const targetBindings = await db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, existing.companyId),
|
|
eq(toolProfileBindings.profileId, target.id),
|
|
),
|
|
);
|
|
const targetKeys = new Set(
|
|
targetBindings.map(
|
|
(binding) => `${binding.targetType}:${binding.targetId}`,
|
|
),
|
|
);
|
|
const copiedBindings = details.bindings.filter(
|
|
(binding) =>
|
|
!targetKeys.has(`${binding.targetType}:${binding.targetId}`),
|
|
);
|
|
if (copiedBindings.length > 0) {
|
|
await db.insert(toolProfileBindings).values(
|
|
copiedBindings.map((binding) => ({
|
|
companyId: binding.companyId,
|
|
profileId: target.id,
|
|
targetType: binding.targetType,
|
|
targetId: binding.targetId,
|
|
priority: binding.priority,
|
|
metadata: binding.metadata ?? {},
|
|
createdByAgentId: binding.createdByAgentId,
|
|
createdByUserId: binding.createdByUserId,
|
|
})),
|
|
);
|
|
reassignedBindingCount = copiedBindings.length;
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(toolProfiles.id, target.id));
|
|
}
|
|
}
|
|
|
|
const [deleted] = await db
|
|
.delete(toolProfiles)
|
|
.where(eq(toolProfiles.id, existing.id))
|
|
.returning();
|
|
if (!deleted) throw notFound("Tool profile not found");
|
|
return {
|
|
profile: toProfile(deleted),
|
|
summary: details.summary,
|
|
reassignedToProfileId: input.reassignToProfileId ?? null,
|
|
reassignedBindingCount,
|
|
};
|
|
}),
|
|
|
|
addProfileEntry: async (
|
|
profileId: string,
|
|
input: CreateToolProfileEntryForProfile, managementContext?: ConnectionManagementContext,
|
|
): Promise<ToolProfileEntry> => db.transaction(async tx => {
|
|
await managementContext?.reauthorize();
|
|
const db = tx as unknown as Db;
|
|
const profile = await getProfileRow(profileId, undefined, tx);
|
|
await assertProfileEntryInputs(tx, profile.companyId, [input]);
|
|
await lockProfileEntryReferences(tx, profile.companyId, [input]);
|
|
await tx.select({ id: toolProfiles.id }).from(toolProfiles).where(eq(toolProfiles.id, profile.id)).for("no key update");
|
|
const [row] = await db
|
|
.insert(toolProfileEntries)
|
|
.values({
|
|
companyId: profile.companyId,
|
|
profileId: profile.id,
|
|
selectorType: input.selectorType,
|
|
effect: input.effect ?? "include",
|
|
applicationId: input.applicationId ?? null,
|
|
connectionId: input.connectionId ?? null,
|
|
catalogEntryId: input.catalogEntryId ?? null,
|
|
toolName: input.toolName ?? null,
|
|
riskLevel: input.riskLevel ?? null,
|
|
conditions: input.conditions ?? null,
|
|
})
|
|
.returning();
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(toolProfiles.id, profile.id));
|
|
return toProfileEntry(row);
|
|
}),
|
|
|
|
getProfileEntry: async (entryId: string): Promise<ToolProfileEntry> => {
|
|
const [row] = await db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.id, entryId));
|
|
if (!row) throw notFound("Tool profile entry not found");
|
|
return toProfileEntry(row);
|
|
},
|
|
|
|
updateProfileEntry: async (
|
|
entryId: string,
|
|
input: UpdateToolProfileEntry, managementContext?: ConnectionManagementContext,
|
|
): Promise<ToolProfileEntry> => db.transaction(async tx => {
|
|
await managementContext?.reauthorize();
|
|
const db = tx as unknown as Db;
|
|
const [existing] = await db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.id, entryId));
|
|
if (!existing) throw notFound("Tool profile entry not found");
|
|
const next: CreateToolProfileEntryForProfile = {
|
|
selectorType: input.selectorType ?? existing.selectorType,
|
|
effect: input.effect ?? existing.effect,
|
|
applicationId: input.applicationId ?? existing.applicationId,
|
|
connectionId: input.connectionId ?? existing.connectionId,
|
|
catalogEntryId: input.catalogEntryId ?? existing.catalogEntryId,
|
|
toolName: input.toolName ?? existing.toolName,
|
|
riskLevel: input.riskLevel ?? existing.riskLevel,
|
|
conditions: input.conditions ?? existing.conditions,
|
|
};
|
|
await assertProfileEntryInputs(tx, existing.companyId, [next]);
|
|
await lockProfileEntryReferences(tx, existing.companyId, [next]);
|
|
await tx.select({ id: toolProfiles.id }).from(toolProfiles).where(eq(toolProfiles.id, existing.profileId)).for("no key update");
|
|
const [row] = await db
|
|
.update(toolProfileEntries)
|
|
.set({
|
|
selectorType: next.selectorType,
|
|
effect: next.effect ?? "include",
|
|
applicationId: next.applicationId ?? null,
|
|
connectionId: next.connectionId ?? null,
|
|
catalogEntryId: next.catalogEntryId ?? null,
|
|
toolName: next.toolName ?? null,
|
|
riskLevel: next.riskLevel ?? null,
|
|
conditions: next.conditions ?? null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolProfileEntries.id, entryId))
|
|
.returning();
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(toolProfiles.id, existing.profileId));
|
|
return toProfileEntry(row);
|
|
}),
|
|
|
|
deleteProfileEntry: async (entryId: string, managementContext?: ConnectionManagementContext): Promise<ToolProfileEntry> => db.transaction(async tx => {
|
|
await managementContext?.reauthorize();
|
|
const db = tx as unknown as Db;
|
|
const [existing] = await tx.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, entryId));
|
|
if (!existing) throw notFound("Tool profile entry not found");
|
|
await tx.select({ id: toolProfiles.id }).from(toolProfiles).where(eq(toolProfiles.id, existing.profileId)).for("no key update");
|
|
const [row] = await db
|
|
.delete(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.id, entryId))
|
|
.returning();
|
|
if (!row) throw notFound("Tool profile entry not found");
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(toolProfiles.id, row.profileId));
|
|
return toProfileEntry(row);
|
|
}),
|
|
|
|
bindProfile: async (
|
|
profileId: string,
|
|
input: CreateToolProfileBindingForProfile,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolProfileBinding> => db.transaction(async tx => {
|
|
await actor?.managementContext?.reauthorize();
|
|
const db = tx as unknown as Db;
|
|
const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update");
|
|
if (!profile) throw notFound("Tool profile not found");
|
|
await assertTargetExists(
|
|
profile.companyId,
|
|
input.targetType,
|
|
input.targetId,
|
|
tx,
|
|
);
|
|
const [row] = await db
|
|
.insert(toolProfileBindings)
|
|
.values({
|
|
companyId: profile.companyId,
|
|
profileId: profile.id,
|
|
targetType: input.targetType,
|
|
targetId: input.targetId,
|
|
priority: input.priority ?? 100,
|
|
metadata: input.metadata ?? {},
|
|
createdByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
createdByUserId:
|
|
connectionActorUserId(actor),
|
|
})
|
|
.returning();
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(toolProfiles.id, profile.id));
|
|
return toProfileBinding(row);
|
|
}),
|
|
|
|
unbindProfile: async (
|
|
profileId: string,
|
|
input: UnbindToolProfileBinding, managementContext?: ConnectionManagementContext,
|
|
): Promise<{ unbound: number }> => db.transaction(async tx => {
|
|
await managementContext?.reauthorize();
|
|
const db = tx as unknown as Db;
|
|
const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update");
|
|
if (!profile) throw notFound("Tool profile not found");
|
|
await assertTargetExists(
|
|
profile.companyId,
|
|
input.targetType,
|
|
input.targetId,
|
|
tx,
|
|
);
|
|
const rows = await db
|
|
.delete(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, profile.companyId),
|
|
eq(toolProfileBindings.profileId, profile.id),
|
|
eq(toolProfileBindings.targetType, input.targetType),
|
|
eq(toolProfileBindings.targetId, input.targetId),
|
|
),
|
|
)
|
|
.returning({ id: toolProfileBindings.id });
|
|
if (rows.length > 0) {
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(toolProfiles.id, profile.id));
|
|
}
|
|
return { unbound: rows.length };
|
|
})
|
|
};
|
|
}
|