fix(apps): include connection lifecycle activity

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-20 21:20:02 +00:00
1 parent 8ed119deb6
commit 5f7926a5d5
9 files changed
+449 -223

No files matched your search

+19 -1
View File
@@ -3455,6 +3455,16 @@ rl.on("line", (line) => {
createdAt: new Date(now - 500),
},
]).returning();
const [connectedEvent] = await db.insert(activityLog).values({
companyId: company.id,
actorType: "system",
actorId: "system",
action: "tool_app.connected",
entityType: "tool_connection",
entityId: connection!.id,
details: { galleryKey: "mail" },
createdAt: new Date(now - 45 * 24 * 60 * 60 * 1000),
}).returning();
const app = createGatewayRouteApp(db, createTestToolGatewayService(db), {
type: "board",
@@ -3467,13 +3477,21 @@ rl.on("line", (line) => {
const allActivity = await request(app)
.get("/api/tool-gateway/audit")
.query({ companyId: company.id, window: "24h" });
.query({ companyId: company.id });
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,
connectedEvent!.id,
]);
expect(allActivity.body.events.find((event: { id: string }) => event.id === connectedEvent!.id)).toMatchObject({
action: "tool_connection.app_connected",
connectionId: connection!.id,
applicationId: application!.id,
appDisplayName: "Mail",
lifecycleType: "app_connected",
});
const firstPage = await request(app)
.get("/api/tool-gateway/audit")
+122 -21
View File
@@ -2,7 +2,11 @@ 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 { agents, toolApplications, toolCallEvents, toolConnections, toolInvocations } from "@paperclipai/db";
import { humanizeConnectionDisplayName, type PermissionKey } from "@paperclipai/shared";
import {
humanizeConnectionDisplayName,
type PermissionKey,
type ToolConnectionLifecycleEventType,
} from "@paperclipai/shared";
import {
createToolMcpGatewaySchema,
createToolMcpGatewayTokenSchema,
@@ -12,6 +16,7 @@ import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getActorInfo } fr
import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js";
import { forbidden, HttpError } from "../errors.js";
import { accessService } from "../services/index.js";
import { listConnectionLifecycleEvents } from "../services/tool-connection-activity.js";
const TOOL_ACTIVITY_EVENT_TYPES = [
"call_completed",
@@ -21,11 +26,12 @@ const TOOL_ACTIVITY_EVENT_TYPES = [
"approval_resolved",
] as const;
const TOOL_GATEWAY_WINDOWS: Record<string, number> = {
const TOOL_GATEWAY_WINDOWS: Record<string, number | null> = {
"1h": 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
all: null,
};
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -653,7 +659,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
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 windowFilter = typeof req.query.window === "string" ? req.query.window.trim() : "all";
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)) {
@@ -669,7 +675,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
return;
}
if (!(windowFilter in TOOL_GATEWAY_WINDOWS)) {
res.status(400).json({ error: "window must be one of 1h, 24h, 7d, 30d" });
res.status(400).json({ error: "window must be one of 1h, 24h, 7d, 30d, all" });
return;
}
const cursor = cursorRaw ? decodeAuditCursor(cursorRaw) : null;
@@ -678,11 +684,13 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
return;
}
const windowMs = TOOL_GATEWAY_WINDOWS[windowFilter];
const windowStartedAt = windowMs === null ? null : new Date(Date.now() - windowMs);
const conditions = [
eq(toolCallEvents.companyId, companyId),
inArray(toolCallEvents.eventType, TOOL_ACTIVITY_EVENT_TYPES),
gte(toolCallEvents.createdAt, new Date(Date.now() - TOOL_GATEWAY_WINDOWS[windowFilter])),
];
if (windowStartedAt) conditions.push(gte(toolCallEvents.createdAt, windowStartedAt));
if (cursor) {
conditions.push(or(
lt(toolCallEvents.createdAt, cursor.createdAt),
@@ -715,6 +723,8 @@ 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 event type, tool name, and reason code so paginating stays honest.
let matchedAgentIds: string[] = [];
let matchedConnectionIds: string[] = [];
if (searchRaw) {
const like = `%${searchRaw.replace(/[%_\\]/g, (ch) => `\\${ch}`)}%`;
const [matchAgents, matchApps, matchConnections] = await Promise.all([
@@ -725,9 +735,22 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
db.select({ id: toolConnections.id }).from(toolConnections)
.where(and(eq(toolConnections.companyId, companyId), ilike(toolConnections.name, like))),
]);
const matchedAgentIds = matchAgents.map((r) => r.id);
matchedAgentIds = matchAgents.map((r) => r.id);
const matchedAppIds = matchApps.map((r) => r.id);
const matchedConnectionIds = matchConnections.map((r) => r.id);
matchedConnectionIds = matchConnections.map((r) => r.id);
if (matchedAppIds.length > 0) {
const appConnections = await db
.select({ id: toolConnections.id })
.from(toolConnections)
.where(and(
eq(toolConnections.companyId, companyId),
inArray(toolConnections.applicationId, matchedAppIds),
));
matchedConnectionIds = [...new Set([
...matchedConnectionIds,
...appConnections.map((connection) => connection.id),
])];
}
const searchClauses = [
ilike(toolCallEvents.eventType, like),
ilike(toolCallEvents.toolName, like),
@@ -781,36 +804,112 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
.orderBy(desc(toolCallEvents.createdAt), desc(toolCallEvents.id))
.limit(limit + 1);
const hasMore = page.length > limit;
const visible = hasMore ? page.slice(0, limit) : page;
let lifecycleConnectionIds: string[] | undefined;
if (gatewayFilter || outcomeFilter) {
lifecycleConnectionIds = [];
} else if (appFilter) {
lifecycleConnectionIds = (await db
.select({ id: toolConnections.id })
.from(toolConnections)
.where(and(
eq(toolConnections.companyId, companyId),
or(eq(toolConnections.id, appFilter), eq(toolConnections.applicationId, appFilter)),
)))
.map((row) => row.id);
}
const lifecycleEvents = await listConnectionLifecycleEvents(db, {
companyId,
connectionIds: lifecycleConnectionIds,
agentId: agentFilter,
since: windowStartedAt,
cursor,
search: searchRaw,
matchedAgentIds,
matchedConnectionIds,
limit: limit + 1,
});
const candidates = [
...page.map((item) => ({ kind: "call" as const, id: item.row.id, createdAt: item.row.createdAt, item })),
...lifecycleEvents.map((item) => ({ kind: "lifecycle" as const, id: item.id, createdAt: item.createdAt, item })),
].sort((a, b) => {
const byTime = b.createdAt.getTime() - a.createdAt.getTime();
return byTime !== 0 ? byTime : b.id.localeCompare(a.id);
});
const hasMore = candidates.length > limit;
const visible = candidates.slice(0, limit);
const agentIds = [...new Set(visible.flatMap((item) => [
item.row.agentId,
item.invocationAgentId,
item.kind === "call" ? item.item.row.agentId : item.item.agentId,
item.kind === "call" ? item.item.invocationAgentId : null,
]).filter((id): id is string => Boolean(id)))];
const applicationIds = [...new Set(visible.flatMap((item) => [
item.row.applicationId,
item.invocationApplicationId,
item.kind === "call" ? item.item.row.applicationId : null,
item.kind === "call" ? item.item.invocationApplicationId : null,
]).filter((id): id is string => Boolean(id)))];
const connectionIds = [...new Set(visible.flatMap((item) => [
item.row.connectionId,
item.invocationConnectionId,
item.kind === "call" ? item.item.row.connectionId : item.item.connectionId,
item.kind === "call" ? item.item.invocationConnectionId : null,
]).filter((id): id is string => Boolean(id)))];
const [agentRows, applicationRows, connectionRows] = await Promise.all([
const [agentRows, connectionRows] = await Promise.all([
agentIds.length > 0
? db.select({ id: agents.id, name: agents.name }).from(agents).where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds)))
: [],
applicationIds.length > 0
? db.select({ id: toolApplications.id, name: toolApplications.name }).from(toolApplications).where(and(eq(toolApplications.companyId, companyId), inArray(toolApplications.id, applicationIds)))
: [],
connectionIds.length > 0
? db.select({ id: toolConnections.id, name: toolConnections.name, applicationId: toolConnections.applicationId }).from(toolConnections).where(and(eq(toolConnections.companyId, companyId), inArray(toolConnections.id, connectionIds)))
: [],
]);
const allApplicationIds = [...new Set([
...applicationIds,
...connectionRows.map((row) => row.applicationId),
])];
const applicationRows = allApplicationIds.length > 0
? await db.select({ id: toolApplications.id, name: toolApplications.name }).from(toolApplications)
.where(and(eq(toolApplications.companyId, companyId), inArray(toolApplications.id, allApplicationIds)))
: [];
const agentsById = new Map(agentRows.map((row) => [row.id, row]));
const applicationsById = new Map(applicationRows.map((row) => [row.id, row]));
const connectionsById = new Map(connectionRows.map((row) => [row.id, row]));
const events = visible.map((item) => {
const events = visible.map((candidate) => {
if (candidate.kind === "lifecycle") {
const lifecycle = candidate.item;
const connection = connectionsById.get(lifecycle.connectionId) ?? null;
const applicationId = connection?.applicationId ?? null;
const application = applicationId ? applicationsById.get(applicationId) ?? null : null;
const appDisplayName = connection
? humanizeConnectionDisplayName(connection)
: application
? humanizeConnectionDisplayName(application.name)
: null;
return {
id: lifecycle.id,
companyId,
action: `tool_connection.${lifecycle.type}`,
actorType: lifecycle.actorType,
actorId: lifecycle.actorId,
entityType: "tool_connection",
entityId: lifecycle.connectionId,
details: { ...(lifecycle.details ?? {}), lifecycleType: lifecycle.type },
createdAt: lifecycle.createdAt,
runId: null,
agentId: lifecycle.agentId,
agentDisplayName: lifecycle.agentId
? agentsById.get(lifecycle.agentId)?.name ?? "Unknown agent"
: null,
actorDisplayName: lifecycle.actorDisplayName,
applicationId,
connectionId: lifecycle.connectionId,
appDisplayName,
applicationDisplayName: application ? humanizeConnectionDisplayName(application.name) : null,
connectionDisplayName: connection ? humanizeConnectionDisplayName(connection) : null,
toolDisplayName: null,
lifecycleType: lifecycle.type,
normalizedOutcome: "unknown" as const,
invocation: null,
};
}
const item = candidate.item;
const row = item.row;
const agentId = row.agentId ?? item.invocationAgentId;
const connectionId = row.connectionId ?? item.invocationConnectionId;
@@ -857,12 +956,14 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
runId: row.runId,
agentId,
agentDisplayName: agentId ? agentsById.get(agentId)?.name ?? "Unknown agent" : null,
actorDisplayName: agentId ? agentsById.get(agentId)?.name ?? "Unknown agent" : null,
applicationId,
connectionId,
appDisplayName,
applicationDisplayName: application ? humanizeConnectionDisplayName(application.name) : null,
connectionDisplayName: connection ? humanizeConnectionDisplayName(connection) : null,
toolDisplayName: rawToolName ? humanizeConnectionDisplayName(rawToolName) : null,
lifecycleType: null as ToolConnectionLifecycleEventType | null,
normalizedOutcome: normalizedAuditOutcome(row.eventType, row.outcome, row.decision),
invocation: item.invocationId && item.invocationToolName && item.invocationStatus && item.invocationApprovalState
? {
@@ -883,7 +984,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
};
});
const last = visible.at(-1)?.row;
const last = visible.at(-1);
res.json({
events,
nextCursor: hasMore && last ? encodeAuditCursor({ createdAt: last.createdAt, id: last.id }) : null,
+6 -176
View File
@@ -3,7 +3,6 @@ import { readFileSync } from "node:fs";
import { and, asc, desc, eq, gte, inArray, isNull, lt, max, ne, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agents,
connectionGrants,
connectionTokenIssuances,
@@ -78,8 +77,6 @@ import type {
ToolActionRequestListItem,
ToolActionRequestStatus,
ToolConnectionActivityResponse,
ToolConnectionLifecycleEvent,
ToolConnectionLifecycleEventType,
ToolAppConnectionActionSummary,
ToolExampleInstallResult,
ToolExampleSmokeCheck,
@@ -146,6 +143,7 @@ import {
} from "./tool-profile-binding-precedence.js";
import { recordToolRuntimeAuditWriteFailure, TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC } from "./tool-runtime-metrics.js";
import { createToolRuntimeSupervisor, ToolRuntimeSupervisorError } from "./tool-runtime-supervisor.js";
import { listConnectionLifecycleEvents } from "./tool-connection-activity.js";
type ActorInfo = {
actorType?: "agent" | "user" | "system" | "plugin";
@@ -1351,47 +1349,6 @@ function userFallbackName(userId: string): string {
return userId;
}
/** Activity-log actions that map to a connection lifecycle event on the Activity tab (PAP-11284). */
const LIFECYCLE_ACTIVITY_LOG_ACTIONS = [
"tool_app.connected",
"tool_app.oauth_connected",
"tool_example.installed",
"tool_app.reconnected",
"tool_connection.archived",
"tool_connection.updated",
] as const;
/**
* Map a connection-scoped activity-log row to a lifecycle event type, or null
* when it isn't an operator-visible lifecycle change. A `tool_connection.updated`
* row only surfaces when the route tagged it with a `lifecycle` discriminator
* (pause/resume/allowlist); plain settings edits stay out of the feed.
*/
function activityLogActionToLifecycleType(
action: string,
details: Record<string, unknown> | null,
): ToolConnectionLifecycleEventType | null {
switch (action) {
case "tool_app.connected":
case "tool_app.oauth_connected":
case "tool_example.installed":
return "app_connected";
case "tool_app.reconnected":
return "reconnected";
case "tool_connection.archived":
return "disconnected";
case "tool_connection.updated": {
const lifecycle = typeof details?.lifecycle === "string" ? details.lifecycle : null;
if (lifecycle === "paused") return "app_paused";
if (lifecycle === "resumed") return "app_resumed";
if (lifecycle === "allowlist_changed") return "allowlist_changed";
return null;
}
default:
return null;
}
}
function denialReasonForDecision(
invocation: typeof toolInvocations.$inferSelect,
latestAuditEvent: typeof toolCallEvents.$inferSelect | null,
@@ -7585,137 +7542,6 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
};
}
/**
* Build the connection lifecycle timeline for the Activity tab (PAP-11284) by
* surfacing two existing audit sources scoped to this connection:
* - `activity_log` rows (connect / pause / resume / allowlist / reconnect / disconnect)
* - `tool_access_audit_events` catalog refreshes that quarantined new actions
* Actors are resolved to display names (agent name or user name/email).
*/
async function listConnectionLifecycleEvents(
connection: typeof toolConnections.$inferSelect,
limit: number,
): Promise<ToolConnectionLifecycleEvent[]> {
const [logRows, quarantineRows] = await Promise.all([
db
.select()
.from(activityLog)
.where(
and(
eq(activityLog.companyId, connection.companyId),
eq(activityLog.entityType, "tool_connection"),
eq(activityLog.entityId, connection.id),
inArray(activityLog.action, [...LIFECYCLE_ACTIVITY_LOG_ACTIONS]),
),
)
.orderBy(desc(activityLog.createdAt))
.limit(limit),
db
.select()
.from(toolAccessAuditEvents)
.where(
and(
eq(toolAccessAuditEvents.companyId, connection.companyId),
eq(toolAccessAuditEvents.connectionId, connection.id),
eq(toolAccessAuditEvents.action, "tool_connection.catalog_refresh"),
sql`(${toolAccessAuditEvents.details}->>'quarantinedCount')::int > 0`,
),
)
.orderBy(desc(toolAccessAuditEvents.createdAt))
.limit(limit),
]);
type Pending = {
id: string;
type: ToolConnectionLifecycleEventType;
actorType: ToolConnectionLifecycleEvent["actorType"];
actorId: string | null;
agentId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
};
const pending: Pending[] = [];
for (const row of logRows) {
const type = activityLogActionToLifecycleType(row.action, row.details ?? null);
if (!type) continue;
pending.push({
id: row.id,
type,
actorType: (row.actorType as Pending["actorType"]) ?? "system",
actorId: row.actorId ?? null,
agentId: row.agentId ?? null,
details: row.details ?? null,
createdAt: row.createdAt,
});
}
for (const row of quarantineRows) {
const count = Number((row.details as Record<string, unknown> | null)?.quarantinedCount ?? 0);
pending.push({
id: row.id,
type: "actions_quarantined",
actorType: (row.actorType as Pending["actorType"]) ?? "system",
actorId: row.actorId ?? null,
agentId: null,
details: { count: Number.isFinite(count) ? count : 0 },
createdAt: row.createdAt,
});
}
pending.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const limited = pending.slice(0, limit);
// Resolve actor display names in batch. Agent actors carry their id in
// `agentId` (activity log) or `actorId` (audit events); user actors carry a
// user id in `actorId`.
const agentIds = new Set<string>();
const userIds = new Set<string>();
for (const item of limited) {
if (item.agentId) agentIds.add(item.agentId);
if (item.actorType === "agent" && item.actorId) agentIds.add(item.actorId);
if (item.actorType === "user" && item.actorId && item.actorId !== "board") userIds.add(item.actorId);
}
const agentRows = agentIds.size
? await db
.select({ id: agents.id, name: agents.name })
.from(agents)
.where(and(eq(agents.companyId, connection.companyId), inArray(agents.id, [...agentIds])))
: [];
const userRows = userIds.size
? await db
.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email })
.from(authUsers)
.where(inArray(authUsers.id, [...userIds]))
: [];
const agentNames = new Map(agentRows.map((agent) => [agent.id, agent.name]));
const userNames = new Map(
userRows.map((user) => [user.id, user.name?.trim() || user.email?.trim() || user.id]),
);
return limited.map((item) => {
let actorDisplayName: string | null = null;
if (item.agentId) actorDisplayName = agentNames.get(item.agentId) ?? null;
else if (item.actorType === "agent" && item.actorId) actorDisplayName = agentNames.get(item.actorId) ?? null;
else if (item.actorType === "user" && item.actorId) {
actorDisplayName = item.actorId === "board"
? "The board"
: userNames.get(item.actorId) ?? userFallbackName(item.actorId);
}
return {
id: item.id,
connectionId: connection.id,
type: item.type,
actorType: item.actorType,
actorId: item.actorId,
agentId: item.agentId,
actorDisplayName,
details: item.details,
createdAt: item.createdAt,
};
});
}
return {
approvedStdioTemplates: async (companyId: string): Promise<ToolStdioCommandTemplate[]> => {
const adminTemplates = await db
@@ -8490,7 +8316,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
]),
);
const lifecycleEvents = await listConnectionLifecycleEvents(connection, safeLimit);
const lifecycleEvents = await listConnectionLifecycleEvents(db, {
companyId: connection.companyId,
connectionIds: [connection.id],
limit: safeLimit,
});
return {
connectionId: connection.id,
@@ -0,0 +1,229 @@
import { and, desc, eq, gte, ilike, inArray, lt, or, sql, type SQL } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agents,
authUsers,
toolAccessAuditEvents,
} from "@paperclipai/db";
import type {
ToolConnectionLifecycleEvent,
ToolConnectionLifecycleEventType,
} from "@paperclipai/shared";
/** Activity-log actions rendered as connection lifecycle rows. */
export const CONNECTION_LIFECYCLE_ACTIONS = [
"tool_app.connected",
"tool_app.oauth_connected",
"tool_example.installed",
"tool_app.reconnected",
"tool_connection.archived",
"tool_connection.updated",
] as const;
export function connectionLifecycleType(
action: string,
details: Record<string, unknown> | null,
): ToolConnectionLifecycleEventType | null {
switch (action) {
case "tool_app.connected":
case "tool_app.oauth_connected":
case "tool_example.installed":
return "app_connected";
case "tool_app.reconnected":
return "reconnected";
case "tool_connection.archived":
return "disconnected";
case "tool_connection.updated": {
const lifecycle = typeof details?.lifecycle === "string" ? details.lifecycle : null;
if (lifecycle === "paused") return "app_paused";
if (lifecycle === "resumed") return "app_resumed";
if (lifecycle === "allowlist_changed") return "allowlist_changed";
return null;
}
default:
return null;
}
}
type ActivityCursor = { createdAt: Date; id: string };
type ListConnectionLifecycleEventsInput = {
companyId: string;
/** Omit for every connection in the company. An empty list returns no rows. */
connectionIds?: string[];
agentId?: string | null;
since?: Date | null;
cursor?: ActivityCursor | null;
search?: string | null;
matchedAgentIds?: string[];
matchedConnectionIds?: string[];
limit: number;
};
function userFallbackName(userId: string): string {
if (userId === "local-board") return "Board";
return userId;
}
/**
* Read the lifecycle half of a connection activity timeline. Both the
* per-connection page and the aggregate Apps Activity page call this helper so
* they cannot drift onto different tables or event vocabularies.
*/
export async function listConnectionLifecycleEvents(
db: Db,
input: ListConnectionLifecycleEventsInput,
): Promise<ToolConnectionLifecycleEvent[]> {
if (input.connectionIds?.length === 0) return [];
const safeLimit = Math.max(1, Math.min(101, Math.floor(input.limit)));
const activityConditions: SQL[] = [
eq(activityLog.companyId, input.companyId),
eq(activityLog.entityType, "tool_connection"),
inArray(activityLog.action, [...CONNECTION_LIFECYCLE_ACTIONS]),
];
const quarantineConditions: SQL[] = [
eq(toolAccessAuditEvents.companyId, input.companyId),
eq(toolAccessAuditEvents.action, "tool_connection.catalog_refresh"),
sql`(${toolAccessAuditEvents.details}->>'quarantinedCount')::int > 0`,
];
if (input.connectionIds) {
activityConditions.push(inArray(activityLog.entityId, input.connectionIds));
quarantineConditions.push(inArray(toolAccessAuditEvents.connectionId, input.connectionIds));
}
if (input.agentId) {
activityConditions.push(eq(activityLog.agentId, input.agentId));
}
if (input.since) {
activityConditions.push(gte(activityLog.createdAt, input.since));
quarantineConditions.push(gte(toolAccessAuditEvents.createdAt, input.since));
}
if (input.cursor) {
activityConditions.push(or(
lt(activityLog.createdAt, input.cursor.createdAt),
and(eq(activityLog.createdAt, input.cursor.createdAt), lt(activityLog.id, input.cursor.id)),
)!);
quarantineConditions.push(or(
lt(toolAccessAuditEvents.createdAt, input.cursor.createdAt),
and(
eq(toolAccessAuditEvents.createdAt, input.cursor.createdAt),
lt(toolAccessAuditEvents.id, input.cursor.id),
),
)!);
}
if (input.search) {
const like = `%${input.search.replace(/[%_\\]/g, (ch) => `\\${ch}`)}%`;
const activitySearch: SQL[] = [
ilike(activityLog.action, like),
sql`${activityLog.details}::text ilike ${like}`,
];
const quarantineSearch: SQL[] = [
ilike(toolAccessAuditEvents.action, like),
sql`${toolAccessAuditEvents.details}::text ilike ${like}`,
];
if (input.matchedAgentIds?.length) {
activitySearch.push(inArray(activityLog.agentId, input.matchedAgentIds));
}
if (input.matchedConnectionIds?.length) {
activitySearch.push(inArray(activityLog.entityId, input.matchedConnectionIds));
quarantineSearch.push(inArray(toolAccessAuditEvents.connectionId, input.matchedConnectionIds));
}
activityConditions.push(or(...activitySearch)!);
quarantineConditions.push(or(...quarantineSearch)!);
}
const [logRows, quarantineRows] = await Promise.all([
db
.select()
.from(activityLog)
.where(and(...activityConditions))
.orderBy(desc(activityLog.createdAt), desc(activityLog.id))
.limit(safeLimit),
input.agentId
? []
: db
.select()
.from(toolAccessAuditEvents)
.where(and(...quarantineConditions))
.orderBy(desc(toolAccessAuditEvents.createdAt), desc(toolAccessAuditEvents.id))
.limit(safeLimit),
]);
type Pending = Omit<ToolConnectionLifecycleEvent, "actorDisplayName">;
const pending: Pending[] = [];
for (const row of logRows) {
const type = connectionLifecycleType(row.action, row.details ?? null);
if (!type) continue;
pending.push({
id: row.id,
connectionId: row.entityId,
type,
actorType: (row.actorType as Pending["actorType"]) ?? "system",
actorId: row.actorId ?? null,
agentId: row.agentId ?? null,
details: row.details ?? null,
createdAt: row.createdAt,
});
}
for (const row of quarantineRows) {
if (!row.connectionId) continue;
const count = Number((row.details as Record<string, unknown> | null)?.quarantinedCount ?? 0);
pending.push({
id: row.id,
connectionId: row.connectionId,
type: "actions_quarantined",
actorType: (row.actorType as Pending["actorType"]) ?? "system",
actorId: row.actorId ?? null,
agentId: null,
details: { count: Number.isFinite(count) ? count : 0 },
createdAt: row.createdAt,
});
}
pending.sort((a, b) => {
const byTime = b.createdAt.getTime() - a.createdAt.getTime();
return byTime !== 0 ? byTime : b.id.localeCompare(a.id);
});
const limited = pending.slice(0, safeLimit);
const agentIds = new Set<string>();
const userIds = new Set<string>();
for (const item of limited) {
if (item.agentId) agentIds.add(item.agentId);
if (item.actorType === "agent" && item.actorId) agentIds.add(item.actorId);
if (item.actorType === "user" && item.actorId && item.actorId !== "board") userIds.add(item.actorId);
}
const [agentRows, userRows] = await Promise.all([
agentIds.size
? db
.select({ id: agents.id, name: agents.name })
.from(agents)
.where(and(eq(agents.companyId, input.companyId), inArray(agents.id, [...agentIds])))
: [],
userIds.size
? db
.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email })
.from(authUsers)
.where(inArray(authUsers.id, [...userIds]))
: [],
]);
const agentNames = new Map(agentRows.map((agent) => [agent.id, agent.name]));
const userNames = new Map(
userRows.map((user) => [user.id, user.name?.trim() || user.email?.trim() || user.id]),
);
return limited.map((item) => {
let actorDisplayName: string | null = null;
if (item.agentId) actorDisplayName = agentNames.get(item.agentId) ?? null;
else if (item.actorType === "agent" && item.actorId) {
actorDisplayName = agentNames.get(item.actorId) ?? null;
} else if (item.actorType === "user" && item.actorId) {
actorDisplayName = item.actorId === "board"
? "The board"
: userNames.get(item.actorId) ?? userFallbackName(item.actorId);
}
return { ...item, actorDisplayName };
});
}
+4 -1
View File
@@ -38,6 +38,7 @@ import type {
AppDefinition,
ToolAppsAttentionResponse,
ToolConnectionActivityResponse,
ToolConnectionLifecycleEventType,
ToolConnectionTestAgentsResponse,
ToolConnectionTestCallResult,
ToolConnectionTestCallStatus,
@@ -203,10 +204,12 @@ export interface ToolGatewayActivityEvent extends ToolGatewayAuditRow {
applicationId: string | null;
connectionId: string | null;
agentDisplayName: string | null;
actorDisplayName?: string | null;
appDisplayName: string | null;
applicationDisplayName: string | null;
connectionDisplayName: string | null;
toolDisplayName: string | null;
lifecycleType?: ToolConnectionLifecycleEventType | null;
normalizedOutcome: ToolAuditOutcome;
invocation: {
id: string;
@@ -229,7 +232,7 @@ export type ToolGatewayActivityResponse = {
nextCursor: string | null;
};
export type ToolAuditWindow = "1h" | "24h" | "7d" | "30d";
export type ToolAuditWindow = "1h" | "24h" | "7d" | "30d" | "all";
export interface ListActivityParams {
gateway?: string | null;
+25
View File
@@ -133,11 +133,36 @@ describe("AuditTab", () => {
expect(container.textContent).toContain("Gmail");
expect(container.textContent).toContain("Blocked");
expect(container.textContent).toContain("Recorded by Paperclip — entries can't be edited.");
expect(listActivityMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ window: "all" }));
// Vocabulary gate: no raw tool ID or ops terms in the sentence list.
expect(container.textContent).not.toContain("mail:send_email");
expect(container.textContent).not.toContain("server-authoritative");
});
it("renders connection lifecycle rows from the per-connection activity source", async () => {
listActivityMock.mockResolvedValue({
events: [event({
id: "lifecycle-1",
action: "tool_connection.app_connected",
actorType: "user",
actorId: "user-1",
agentId: null,
agentDisplayName: null,
actorDisplayName: "Dotta",
lifecycleType: "app_connected",
normalizedOutcome: "unknown",
toolDisplayName: null,
details: { lifecycleType: "app_connected" },
})],
nextCursor: null,
});
await render();
expect(container.textContent).toContain("Dotta connected Gmail");
expect(container.textContent).not.toContain("RecordedDotta connected Gmail");
});
it("expands a row to show the plain reason, the matched rule, and the Details collapse", async () => {
await render();
+39 -5
View File
@@ -48,6 +48,7 @@ const OUTCOME_FILTERS: { value: string; label: string }[] = [
];
const WINDOW_FILTERS: { value: ToolAuditWindow; label: string }[] = [
{ value: "all", label: "All time" },
{ value: "1h", label: "Last 1 hour" },
{ value: "24h", label: "Last 24 hours" },
{ value: "7d", label: "Last 7 days" },
@@ -86,8 +87,38 @@ function formattedArguments(details: Record<string, unknown> | null): string | u
}
}
function lifecycleSummary(event: ToolGatewayActivityEvent): string | null {
if (!event.lifecycleType) return null;
const who = event.actorDisplayName ?? event.agentDisplayName ?? "Someone";
const app = event.appDisplayName ?? event.connectionDisplayName ?? "this app";
const count = detailNumber(event.details, "count") ?? 0;
const added = detailNumber(event.details, "added") ?? 0;
const removed = detailNumber(event.details, "removed") ?? 0;
switch (event.lifecycleType) {
case "app_connected":
return `${who} connected ${app}`;
case "app_paused":
return `${who} paused ${app}`;
case "app_resumed":
return `${who} resumed ${app}`;
case "reconnected":
return `${who} reconnected ${app}`;
case "disconnected":
return `${who} disconnected ${app}`;
case "allowlist_changed":
if (added > 0 && removed === 0) return `${who} added ${added} allowed ${added === 1 ? "item" : "items"} in ${app}`;
if (removed > 0 && added === 0) return `${who} removed ${removed} allowed ${removed === 1 ? "item" : "items"} in ${app}`;
return `${who} updated the allowlist for ${app}`;
case "actions_quarantined":
return `${count} new ${count === 1 ? "action needs" : "actions need"} review in ${app}`;
default:
return `${who} updated ${app}`;
}
}
/** Plain-words "why" for the row expander, keyed off the reason code. */
function plainReason(event: ToolGatewayActivityEvent): string {
if (event.lifecycleType) return "This connection change was recorded in the app's activity history.";
const code = detailString(event.details, "reasonCode");
if (code === "permitted_connections_not_installed") {
return "Permitted connections were not installed, so their tools were not added to this run.";
@@ -138,6 +169,7 @@ function ActivityRow({
const who = event.agentDisplayName ?? "An agent";
const action = event.toolDisplayName ?? "an action";
const app = event.appDisplayName ?? event.connectionDisplayName ?? event.applicationDisplayName ?? null;
const lifecycle = lifecycleSummary(event);
const rawTool = detailString(event.details, "tool") ?? detailString(event.details, "toolName");
const issueId = detailString(event.details, "issueId");
@@ -180,7 +212,9 @@ function ActivityRow({
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
{isRuntimeMcpDeliveryDiagnostic ? (
{lifecycle ? (
<span className="block text-foreground">{lifecycle}</span>
) : isRuntimeMcpDeliveryDiagnostic ? (
<span className="block text-foreground">
<span className="font-medium">{who}</span>'s run received 0 MCP servers —{" "}
<span className="font-medium">{permittedNotInstalledCount ?? permittedNotInstalledConnections.length}</span>{" "}
@@ -199,7 +233,7 @@ function ActivityRow({
)}
</span>
<span className="flex shrink-0 items-center gap-2 whitespace-nowrap">
<OutcomeChip outcome={event.normalizedOutcome} />
{event.lifecycleType ? null : <OutcomeChip outcome={event.normalizedOutcome} />}
<span className="text-xs text-muted-foreground">
· <RelativeTime value={event.createdAt} />
</span>
@@ -293,7 +327,7 @@ export function AuditTab({ companyId }: { companyId: string }) {
const [app, setApp] = useState<string>(ALL);
const [agent, setAgent] = useState<string>(ALL);
const [outcome, setOutcome] = useState<string>(ALL);
const [windowKey, setWindowKey] = useState<ToolAuditWindow>("24h");
const [windowKey, setWindowKey] = useState<ToolAuditWindow>("all");
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
@@ -329,7 +363,7 @@ export function AuditTab({ companyId }: { companyId: string }) {
search: search || undefined,
};
const hasActiveFilters =
app !== ALL || agent !== ALL || outcome !== ALL || windowKey !== "24h" || search.length > 0;
app !== ALL || agent !== ALL || outcome !== ALL || windowKey !== "all" || search.length > 0;
const activity = useInfiniteQuery({
queryKey: queryKeys.tools.activity(companyId, {
@@ -354,7 +388,7 @@ export function AuditTab({ companyId }: { companyId: string }) {
setApp(ALL);
setAgent(ALL);
setOutcome(ALL);
setWindowKey("24h");
setWindowKey("all");
setSearchInput("");
setSearch("");
};
+3 -2
View File
@@ -102,7 +102,7 @@ describe("ToolsAccess", () => {
expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: target, replace: true }));
});
it("uses Profiles as the developer surface entry point", async () => {
it("uses Profiles as the developer entry point without a second page shell", async () => {
await render();
expect(container.querySelector('a[href="/apps/advanced/profiles"]')?.textContent).toContain(
@@ -112,7 +112,8 @@ describe("ToolsAccess", () => {
mockParams.tab = "profiles";
await render();
expect(container.textContent).toContain("Developer tools");
expect(container.textContent).not.toContain("Developer tools");
expect(container.textContent).toContain("Tool profiles");
expect(container.firstElementChild?.classList.contains("max-w-5xl")).toBe(true);
});
});
+2 -17
View File
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { Settings2, Wrench } from "lucide-react";
import { Wrench } from "lucide-react";
import { Link, Navigate, useParams } from "@/lib/router";
import { cn } from "@/lib/utils";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
@@ -132,20 +132,5 @@ export function ToolsAccess() {
);
}
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5 p-4 sm:p-6">
<div>
<div className="flex items-center gap-2">
<Settings2 className="h-5 w-5 text-muted-foreground" />
<h1 className="text-xl font-bold text-foreground">Developer tools</h1>
</div>
<p className="mt-1.5 max-w-2xl text-sm text-muted-foreground">
Apps is the simple way to connect tools. This Developer area is for wiring your own
servers, tokens, and rules by hand — most teams never need it.
</p>
</div>
<div className="min-h-(--sz-300px)">{renderTab(activeTab, selectedCompanyId)}</div>
</div>
);
return <div className="max-w-5xl">{renderTab(activeTab, selectedCompanyId)}</div>;
}