fix(apps): aggregate connection activity

Read the cross-app activity feed from the canonical tool call events used by each connection, preserving filters, pagination, gateway scope, and redacted invocation details.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-20 21:20:02 +00:00
1 parent 61bf48f972
commit 8ed119deb6
2 files changed
+215 -83

No files matched your search

+82 -17
View File
@@ -3321,7 +3321,7 @@ rl.on("line", (line) => {
expect(audit.body).toHaveProperty("nextCursor");
});
it("filters, paginates, and enriches tool gateway audit events server-side", async () => {
it("aggregates connection activity with server-side filters, pagination, and enrichment", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const otherAgent = await createAgent(db, company.id);
@@ -3340,6 +3340,25 @@ rl.on("line", (line) => {
status: "active",
enabled: true,
}).returning();
const [profile] = await db.insert(toolProfiles).values({
companyId: company.id,
profileKey: `audit-${randomUUID()}`,
name: `Audit ${randomUUID()}`,
}).returning();
const [gateway, otherGateway] = await db.insert(toolMcpGateways).values([
{
companyId: company.id,
name: `Audit gateway ${randomUUID()}`,
slug: `audit-${randomUUID()}`,
profileId: profile!.id,
},
{
companyId: company.id,
name: `Other gateway ${randomUUID()}`,
slug: `other-${randomUUID()}`,
profileId: profile!.id,
},
]).returning();
const [newerInvocation, olderInvocation, otherInvocation] = await db.insert(toolInvocations).values([
{
companyId: company.id,
@@ -3347,9 +3366,16 @@ rl.on("line", (line) => {
actorId: agent.id,
agentId: agent.id,
runId: run.id,
gatewayId: gateway!.id,
applicationId: application!.id,
connectionId: connection!.id,
toolName: "mail:send_email",
argumentsSummary: { summary: JSON.stringify({ to: "person@example.test", token: "***REDACTED***" }) },
resultSummary: { summary: JSON.stringify({ delivered: true }) },
policyDecision: "allow",
status: "succeeded",
startedAt: new Date(Date.now() - 1_500),
completedAt: new Date(Date.now() - 1_000),
},
{
companyId: company.id,
@@ -3357,6 +3383,7 @@ rl.on("line", (line) => {
actorId: agent.id,
agentId: agent.id,
runId: run.id,
gatewayId: gateway!.id,
applicationId: application!.id,
connectionId: connection!.id,
toolName: "mail:read_email",
@@ -3367,48 +3394,67 @@ rl.on("line", (line) => {
actorId: otherAgent.id,
agentId: otherAgent.id,
runId: run.id,
gatewayId: otherGateway!.id,
applicationId: application!.id,
connectionId: connection!.id,
toolName: "other:delete_everything",
},
]).returning();
const now = Date.now();
await db.insert(activityLog).values([
const callEvents = await db.insert(toolCallEvents).values([
{
companyId: company.id,
eventType: "call_completed",
actorType: "agent",
actorId: agent.id,
action: "tool_gateway.call_completed",
entityType: "issue",
entityId: run.id,
agentId: agent.id,
runId: run.id,
details: { invocationId: newerInvocation!.id, decision: "allow", reasonCode: "tool_completed", tool: "mail:send_email", upstreamToolName: "fixture.todo.list" },
gatewayId: gateway!.id,
applicationId: application!.id,
connectionId: connection!.id,
invocationId: newerInvocation!.id,
toolName: "mail:send_email",
decision: "allow",
reasonCode: "tool_completed",
outcome: "success",
metadata: { upstreamToolName: "fixture.todo.list" },
createdAt: new Date(now - 1_000),
},
{
companyId: company.id,
eventType: "call_completed",
actorType: "agent",
actorId: agent.id,
action: "tool_gateway.call_allowed",
entityType: "issue",
entityId: run.id,
agentId: agent.id,
runId: run.id,
details: { invocationId: olderInvocation!.id, decision: "allow", reasonCode: "profile_allows_tool", tool: "mail:read_email" },
gatewayId: gateway!.id,
applicationId: application!.id,
connectionId: connection!.id,
invocationId: olderInvocation!.id,
toolName: "mail:read_email",
decision: "allow",
reasonCode: "profile_allows_tool",
outcome: "success",
createdAt: new Date(now - 2_000),
},
{
companyId: company.id,
eventType: "call_denied",
actorType: "agent",
actorId: otherAgent.id,
action: "tool_gateway.call_denied",
entityType: "issue",
entityId: run.id,
agentId: otherAgent.id,
runId: run.id,
details: { invocationId: otherInvocation!.id, decision: "deny", reasonCode: "deny_policy_block", tool: "other:delete_everything" },
gatewayId: otherGateway!.id,
applicationId: application!.id,
connectionId: connection!.id,
invocationId: otherInvocation!.id,
toolName: "other:delete_everything",
decision: "deny",
reasonCode: "deny_policy_block",
outcome: "denied",
createdAt: new Date(now - 500),
},
]);
]).returning();
const app = createGatewayRouteApp(db, createTestToolGatewayService(db), {
type: "board",
@@ -3419,9 +3465,19 @@ rl.on("line", (line) => {
isInstanceAdmin: true,
});
const allActivity = await request(app)
.get("/api/tool-gateway/audit")
.query({ companyId: company.id, window: "24h" });
expect(allActivity.status).toBe(200);
expect(allActivity.body.events.map((event: { id: string }) => event.id)).toEqual([
callEvents[2]!.id,
callEvents[0]!.id,
callEvents[1]!.id,
]);
const firstPage = await request(app)
.get("/api/tool-gateway/audit")
.query({ companyId: company.id, app: connection!.id, agent: agent.id, outcome: "allowed", window: "24h", limit: 1 });
.query({ companyId: company.id, gateway: gateway!.id, app: connection!.id, agent: agent.id, outcome: "allowed", window: "24h", limit: 1 });
expect(firstPage.status).toBe(200);
expect(firstPage.body.events).toEqual([
expect.objectContaining({
@@ -3433,6 +3489,14 @@ rl.on("line", (line) => {
appDisplayName: "Mail",
toolDisplayName: "Send Email",
normalizedOutcome: "allowed",
invocation: expect.objectContaining({
id: newerInvocation!.id,
toolName: "mail:send_email",
status: "succeeded",
policyDecision: "allow",
argumentsSummary: expect.objectContaining({ summary: expect.stringContaining("***REDACTED***") }),
resultSummary: expect.objectContaining({ summary: expect.stringContaining("delivered") }),
}),
}),
]);
expect(typeof firstPage.body.nextCursor).toBe("string");
@@ -3441,6 +3505,7 @@ rl.on("line", (line) => {
.get("/api/tool-gateway/audit")
.query({
companyId: company.id,
gateway: gateway!.id,
app: connection!.id,
agent: agent.id,
outcome: "allowed",
@@ -3451,7 +3516,7 @@ rl.on("line", (line) => {
expect(secondPage.status).toBe(200);
expect(secondPage.body.events).toEqual([
expect.objectContaining({
action: "tool_gateway.call_allowed",
action: "tool_gateway.call_completed",
toolDisplayName: "Read Email",
}),
]);
+133 -66
View File
@@ -1,7 +1,7 @@
import { Router, type Request, type Response } from "express";
import { and, desc, eq, gte, ilike, inArray, lt, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { activityLog, agents, toolApplications, toolConnections, toolInvocations } from "@paperclipai/db";
import { agents, toolApplications, toolCallEvents, toolConnections, toolInvocations } from "@paperclipai/db";
import { humanizeConnectionDisplayName, type PermissionKey } from "@paperclipai/shared";
import {
createToolMcpGatewaySchema,
@@ -13,19 +13,13 @@ import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-
import { forbidden, HttpError } from "../errors.js";
import { accessService } from "../services/index.js";
const TOOL_GATEWAY_ACTIONS = [
"tool_gateway.session_created",
"tool_gateway.session_revoked",
"tool_gateway.session_rejected",
"tool_gateway.discovery",
"tool_gateway.call_allowed",
"tool_gateway.call_denied",
"tool_gateway.call_completed",
"tool_gateway.call_failed",
"tool_gateway.call_deferred",
"tool_gateway.approval_requested",
"tool_gateway.runtime_mcp_delivery",
];
const TOOL_ACTIVITY_EVENT_TYPES = [
"call_completed",
"call_failed",
"call_denied",
"approval_requested",
"approval_resolved",
] as const;
const TOOL_GATEWAY_WINDOWS: Record<string, number> = {
"1h": 60 * 60 * 1000,
@@ -194,42 +188,53 @@ function decodeAuditCursor(value: string): { createdAt: Date; id: string } | nul
}
}
function normalizedAuditOutcome(action: string, details: Record<string, unknown> | null | undefined) {
const decision = detailString(details, "decision");
if (action === "tool_gateway.call_completed" || action === "tool_gateway.call_allowed" || decision === "allow" || decision === "approved") return "allowed";
if (action === "tool_gateway.approval_requested" || decision === "require_approval") return "asked_first";
if (action === "tool_gateway.call_deferred" || decision === "defer_runtime") return "waiting";
if (action === "tool_gateway.call_failed") return "failed";
if (action === "tool_gateway.call_denied" || decision === "deny" || decision === "rate_limited") return "blocked";
function toolActivityAction(eventType: string): string {
return `tool_gateway.${eventType}`;
}
function normalizedAuditOutcome(
eventType: string,
outcome: string,
decision: string | null,
) {
if (eventType === "approval_requested") return "asked_first";
if (decision === "defer_runtime") return "waiting";
if (eventType === "call_denied" || outcome === "denied" || decision === "deny") return "blocked";
if (eventType === "call_failed" || ["failure", "timeout", "cancelled"].includes(outcome)) return "failed";
if (eventType === "call_completed" || eventType === "approval_resolved" || outcome === "success" || decision === "allow") return "allowed";
return "unknown";
}
function outcomeCondition(outcome: string) {
if (outcome === "allowed") {
return or(
inArray(activityLog.action, ["tool_gateway.call_allowed", "tool_gateway.call_completed"]),
sql`${activityLog.details}->>'decision' in ('allow', 'approved')`,
eq(toolCallEvents.eventType, "call_completed"),
and(eq(toolCallEvents.eventType, "approval_resolved"), eq(toolCallEvents.outcome, "success")),
eq(toolCallEvents.decision, "allow"),
);
}
if (outcome === "blocked" || outcome === "denied") {
return or(
eq(activityLog.action, "tool_gateway.call_denied"),
sql`${activityLog.details}->>'decision' in ('deny', 'rate_limited')`,
eq(toolCallEvents.eventType, "call_denied"),
eq(toolCallEvents.outcome, "denied"),
eq(toolCallEvents.decision, "deny"),
);
}
if (outcome === "asked_first" || outcome === "approval") {
return or(
eq(activityLog.action, "tool_gateway.approval_requested"),
sql`${activityLog.details}->>'decision' = 'require_approval'`,
);
return eq(toolCallEvents.eventType, "approval_requested");
}
if (outcome === "waiting" || outcome === "deferred") {
return or(
eq(activityLog.action, "tool_gateway.call_deferred"),
sql`${activityLog.details}->>'decision' = 'defer_runtime'`,
return eq(toolCallEvents.decision, "defer_runtime");
}
if (outcome === "failed") {
return and(
or(
eq(toolCallEvents.eventType, "call_failed"),
inArray(toolCallEvents.outcome, ["failure", "timeout", "cancelled"]),
),
sql`${toolCallEvents.decision} is distinct from 'defer_runtime'`,
);
}
if (outcome === "failed") return eq(activityLog.action, "tool_gateway.call_failed");
return null;
}
@@ -644,12 +649,17 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
await assertBoardPermission(req, companyId, "tools:view_audit");
const limitRaw = Number(req.query.limit ?? 100);
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(100, Math.floor(limitRaw))) : 100;
const gatewayFilter = typeof req.query.gateway === "string" ? req.query.gateway.trim() : null;
const appFilter = typeof req.query.app === "string" ? req.query.app.trim() : null;
const agentFilter = typeof req.query.agent === "string" ? req.query.agent.trim() : null;
const outcomeFilter = typeof req.query.outcome === "string" ? req.query.outcome.trim() : null;
const windowFilter = typeof req.query.window === "string" ? req.query.window.trim() : "24h";
const searchRaw = typeof req.query.search === "string" ? req.query.search.trim() : null;
const cursorRaw = typeof req.query.cursor === "string" ? req.query.cursor.trim() : null;
if (gatewayFilter && !uuidPattern.test(gatewayFilter)) {
res.status(400).json({ error: "gateway must be a gateway UUID" });
return;
}
if (appFilter && !uuidPattern.test(appFilter)) {
res.status(400).json({ error: "app must be an applicationId or connectionId UUID" });
return;
@@ -669,29 +679,34 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
}
const conditions = [
eq(activityLog.companyId, companyId),
inArray(activityLog.action, TOOL_GATEWAY_ACTIONS),
gte(activityLog.createdAt, new Date(Date.now() - TOOL_GATEWAY_WINDOWS[windowFilter])),
eq(toolCallEvents.companyId, companyId),
inArray(toolCallEvents.eventType, TOOL_ACTIVITY_EVENT_TYPES),
gte(toolCallEvents.createdAt, new Date(Date.now() - TOOL_GATEWAY_WINDOWS[windowFilter])),
];
if (cursor) {
conditions.push(or(
lt(activityLog.createdAt, cursor.createdAt),
and(eq(activityLog.createdAt, cursor.createdAt), lt(activityLog.id, cursor.id)),
lt(toolCallEvents.createdAt, cursor.createdAt),
and(eq(toolCallEvents.createdAt, cursor.createdAt), lt(toolCallEvents.id, cursor.id)),
)!);
}
if (gatewayFilter) {
conditions.push(or(
eq(toolCallEvents.gatewayId, gatewayFilter),
eq(toolInvocations.gatewayId, gatewayFilter),
)!);
}
if (appFilter) {
conditions.push(or(
eq(toolCallEvents.applicationId, appFilter),
eq(toolCallEvents.connectionId, appFilter),
eq(toolInvocations.applicationId, appFilter),
eq(toolInvocations.connectionId, appFilter),
sql`${activityLog.details}->>'applicationId' = ${appFilter}`,
sql`${activityLog.details}->>'connectionId' = ${appFilter}`,
)!);
}
if (agentFilter) {
conditions.push(or(
eq(activityLog.agentId, agentFilter),
eq(toolCallEvents.agentId, agentFilter),
eq(toolInvocations.agentId, agentFilter),
sql`${activityLog.details}->>'agentId' = ${agentFilter}`,
)!);
}
const outcomeWhere = outcomeFilter ? outcomeCondition(outcomeFilter) : null;
@@ -699,7 +714,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
// Free-text search runs server-side: resolve the term against agent / app /
// connection names first, then OR those matched IDs with direct matches on
// the action name, tool name, and reason code so paginating stays honest.
// the event type, tool name, and reason code so paginating stays honest.
if (searchRaw) {
const like = `%${searchRaw.replace(/[%_\\]/g, (ch) => `\\${ch}`)}%`;
const [matchAgents, matchApps, matchConnections] = await Promise.all([
@@ -714,48 +729,56 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
const matchedAppIds = matchApps.map((r) => r.id);
const matchedConnectionIds = matchConnections.map((r) => r.id);
const searchClauses = [
ilike(activityLog.action, like),
ilike(toolCallEvents.eventType, like),
ilike(toolCallEvents.toolName, like),
ilike(toolCallEvents.reasonCode, like),
sql`${toolCallEvents.metadata}->>'upstreamToolName' ilike ${like}`,
ilike(toolInvocations.toolName, like),
sql`${activityLog.details}->>'tool' ilike ${like}`,
sql`${activityLog.details}->>'toolName' ilike ${like}`,
sql`${activityLog.details}->>'upstreamToolName' ilike ${like}`,
sql`${activityLog.details}->>'reasonCode' ilike ${like}`,
];
if (matchedAgentIds.length > 0) {
searchClauses.push(inArray(activityLog.agentId, matchedAgentIds));
searchClauses.push(inArray(toolCallEvents.agentId, matchedAgentIds));
searchClauses.push(inArray(toolInvocations.agentId, matchedAgentIds));
for (const id of matchedAgentIds) searchClauses.push(sql`${activityLog.details}->>'agentId' = ${id}`);
}
if (matchedAppIds.length > 0) {
searchClauses.push(inArray(toolCallEvents.applicationId, matchedAppIds));
searchClauses.push(inArray(toolInvocations.applicationId, matchedAppIds));
for (const id of matchedAppIds) searchClauses.push(sql`${activityLog.details}->>'applicationId' = ${id}`);
}
if (matchedConnectionIds.length > 0) {
searchClauses.push(inArray(toolCallEvents.connectionId, matchedConnectionIds));
searchClauses.push(inArray(toolInvocations.connectionId, matchedConnectionIds));
for (const id of matchedConnectionIds) searchClauses.push(sql`${activityLog.details}->>'connectionId' = ${id}`);
}
conditions.push(or(...searchClauses)!);
}
const page = await db
.select({
row: activityLog,
row: toolCallEvents,
invocationId: toolInvocations.id,
invocationAgentId: toolInvocations.agentId,
invocationApplicationId: toolInvocations.applicationId,
invocationConnectionId: toolInvocations.connectionId,
invocationToolName: toolInvocations.toolName,
invocationStatus: toolInvocations.status,
invocationPolicyDecision: toolInvocations.policyDecision,
invocationApprovalState: toolInvocations.approvalState,
invocationArgumentsSummary: toolInvocations.argumentsSummary,
invocationResultSummary: toolInvocations.resultSummary,
invocationResultSizeBytes: toolInvocations.resultSizeBytes,
invocationErrorCode: toolInvocations.errorCode,
invocationErrorMessage: toolInvocations.errorMessage,
invocationStartedAt: toolInvocations.startedAt,
invocationCompletedAt: toolInvocations.completedAt,
})
.from(activityLog)
.from(toolCallEvents)
.leftJoin(
toolInvocations,
and(
eq(toolInvocations.companyId, companyId),
sql`${toolInvocations.id}::text = ${activityLog.details}->>'invocationId'`,
eq(toolInvocations.id, toolCallEvents.invocationId),
),
)
.where(and(...conditions))
.orderBy(desc(activityLog.createdAt), desc(activityLog.id))
.orderBy(desc(toolCallEvents.createdAt), desc(toolCallEvents.id))
.limit(limit + 1);
const hasMore = page.length > limit;
@@ -763,15 +786,14 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
const agentIds = [...new Set(visible.flatMap((item) => [
item.row.agentId,
item.invocationAgentId,
detailString(item.row.details, "agentId"),
]).filter((id): id is string => Boolean(id)))];
const applicationIds = [...new Set(visible.flatMap((item) => [
item.row.applicationId,
item.invocationApplicationId,
detailString(item.row.details, "applicationId"),
]).filter((id): id is string => Boolean(id)))];
const connectionIds = [...new Set(visible.flatMap((item) => [
item.row.connectionId,
item.invocationConnectionId,
detailString(item.row.details, "connectionId"),
]).filter((id): id is string => Boolean(id)))];
const [agentRows, applicationRows, connectionRows] = await Promise.all([
agentIds.length > 0
@@ -790,20 +812,49 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
const events = visible.map((item) => {
const row = item.row;
const details = row.details ?? null;
const agentId = row.agentId ?? item.invocationAgentId ?? detailString(details, "agentId");
const connectionId = item.invocationConnectionId ?? detailString(details, "connectionId");
const agentId = row.agentId ?? item.invocationAgentId;
const connectionId = row.connectionId ?? item.invocationConnectionId;
const connection = connectionId ? connectionsById.get(connectionId) ?? null : null;
const applicationId = item.invocationApplicationId ?? detailString(details, "applicationId") ?? connection?.applicationId ?? null;
const applicationId = row.applicationId ?? item.invocationApplicationId ?? connection?.applicationId ?? null;
const application = applicationId ? applicationsById.get(applicationId) ?? null : null;
const rawToolName = item.invocationToolName ?? detailString(details, "tool") ?? detailString(details, "toolName");
const rawToolName = row.toolName ?? item.invocationToolName;
const appDisplayName = connection
? humanizeConnectionDisplayName(connection)
: application
? humanizeConnectionDisplayName(application.name)
: null;
const details = {
...(row.metadata ?? {}),
invocationId: row.invocationId,
actionRequestId: row.actionRequestId,
gatewayId: row.gatewayId,
agentId,
issueId: row.issueId,
runId: row.runId,
applicationId,
connectionId,
tool: rawToolName,
toolName: rawToolName,
decision: row.decision,
matchedPolicyIds: row.matchedPolicyIds,
reasonCode: row.reasonCode,
argumentsSummary: row.argumentsSummary ?? row.requestSummary,
resultSummary: row.resultSummary,
latencyMs: row.latencyMs,
errorCode: row.errorCode,
errorMessage: row.errorMessage,
};
return {
...row,
id: row.id,
companyId: row.companyId,
action: toolActivityAction(row.eventType),
actorType: row.actorType,
actorId: row.actorId,
entityType: "tool_connection",
entityId: connectionId,
details,
createdAt: row.createdAt,
runId: row.runId,
agentId,
agentDisplayName: agentId ? agentsById.get(agentId)?.name ?? "Unknown agent" : null,
applicationId,
@@ -812,7 +863,23 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
applicationDisplayName: application ? humanizeConnectionDisplayName(application.name) : null,
connectionDisplayName: connection ? humanizeConnectionDisplayName(connection) : null,
toolDisplayName: rawToolName ? humanizeConnectionDisplayName(rawToolName) : null,
normalizedOutcome: normalizedAuditOutcome(row.action, details),
normalizedOutcome: normalizedAuditOutcome(row.eventType, row.outcome, row.decision),
invocation: item.invocationId && item.invocationToolName && item.invocationStatus && item.invocationApprovalState
? {
id: item.invocationId,
toolName: item.invocationToolName,
status: item.invocationStatus,
policyDecision: item.invocationPolicyDecision,
approvalState: item.invocationApprovalState,
argumentsSummary: item.invocationArgumentsSummary,
resultSummary: item.invocationResultSummary,
resultSizeBytes: item.invocationResultSizeBytes,
errorCode: item.invocationErrorCode,
errorMessage: item.invocationErrorMessage,
startedAt: item.invocationStartedAt,
completedAt: item.invocationCompletedAt,
}
: null,
};
});