diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index 1da581bff78..8a9b8c299d1 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -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") diff --git a/server/src/routes/tool-gateway.ts b/server/src/routes/tool-gateway.ts index 7344c973f19..c45ac990da9 100644 --- a/server/src/routes/tool-gateway.ts +++ b/server/src/routes/tool-gateway.ts @@ -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 = { +const TOOL_GATEWAY_WINDOWS: Record = { "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, diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index b6f81657054..d6af32ca833 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -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 | 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 { - 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 | 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 | 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(); - const userIds = new Set(); - 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 => { 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, diff --git a/server/src/services/tool-connection-activity.ts b/server/src/services/tool-connection-activity.ts new file mode 100644 index 00000000000..40676cd9a85 --- /dev/null +++ b/server/src/services/tool-connection-activity.ts @@ -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 | 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 { + 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; + 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 | 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(); + const userIds = new Set(); + 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 }; + }); +} diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 2f3bb0e91fa..9aa96583ff7 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -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; diff --git a/ui/src/pages/tools/AuditTab.test.tsx b/ui/src/pages/tools/AuditTab.test.tsx index 571aef4d06b..72f14c9c77b 100644 --- a/ui/src/pages/tools/AuditTab.test.tsx +++ b/ui/src/pages/tools/AuditTab.test.tsx @@ -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(); diff --git a/ui/src/pages/tools/AuditTab.tsx b/ui/src/pages/tools/AuditTab.tsx index f16286e4dba..aef757194cf 100644 --- a/ui/src/pages/tools/AuditTab.tsx +++ b/ui/src/pages/tools/AuditTab.tsx @@ -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 | 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({ )} - {isRuntimeMcpDeliveryDiagnostic ? ( + {lifecycle ? ( + {lifecycle} + ) : isRuntimeMcpDeliveryDiagnostic ? ( {who}'s run received 0 MCP servers โ€”{" "} {permittedNotInstalledCount ?? permittedNotInstalledConnections.length}{" "} @@ -199,7 +233,7 @@ function ActivityRow({ )} - + {event.lifecycleType ? null : } ยท @@ -293,7 +327,7 @@ export function AuditTab({ companyId }: { companyId: string }) { const [app, setApp] = useState(ALL); const [agent, setAgent] = useState(ALL); const [outcome, setOutcome] = useState(ALL); - const [windowKey, setWindowKey] = useState("24h"); + const [windowKey, setWindowKey] = useState("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(""); }; diff --git a/ui/src/pages/tools/ToolsAccess.test.tsx b/ui/src/pages/tools/ToolsAccess.test.tsx index 325e4414b52..fb56aebc5d1 100644 --- a/ui/src/pages/tools/ToolsAccess.test.tsx +++ b/ui/src/pages/tools/ToolsAccess.test.tsx @@ -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); }); }); diff --git a/ui/src/pages/tools/ToolsAccess.tsx b/ui/src/pages/tools/ToolsAccess.tsx index 236ae69fa19..c755e6ee99f 100644 --- a/ui/src/pages/tools/ToolsAccess.tsx +++ b/ui/src/pages/tools/ToolsAccess.tsx @@ -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 ( -
-
-
- -

Developer tools

-
-

- 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. -

-
- -
{renderTab(activeTab, selectedCompanyId)}
-
- ); + return
{renderTab(activeTab, selectedCompanyId)}
; }