diff --git a/packages/api/src/activity/route.test.ts b/packages/api/src/activity/route.test.ts index 9fe0368c..abed9cb2 100644 --- a/packages/api/src/activity/route.test.ts +++ b/packages/api/src/activity/route.test.ts @@ -118,6 +118,7 @@ describe('handleListActivityRoute', () => { via: 'mcp', taskId: 'task-1', durationMs: 340, + error: null, createdAt: new Date('2026-10-15T10:00:00Z'), }; listAuditEntries.mockResolvedValue(Ok([entry, {...entry, id: 'e2', taskId: null, agentId: null, durationMs: null}])); @@ -155,6 +156,7 @@ describe('handleListActivityRoute', () => { via: 'mcp', taskId: 'task-1', durationMs: 340, + error: null, at: '2026-10-15T10:00:00.000Z', }, { @@ -169,6 +171,7 @@ describe('handleListActivityRoute', () => { via: 'mcp', taskId: null, durationMs: null, + error: null, at: '2026-10-15T10:00:00.000Z', }, ], diff --git a/packages/api/src/activity/route.ts b/packages/api/src/activity/route.ts index 70fda235..9d1c5fb3 100644 --- a/packages/api/src/activity/route.ts +++ b/packages/api/src/activity/route.ts @@ -21,7 +21,10 @@ export type AuditEntryResponse = { action: string; recordTitle: string | null; query: string | null; - outcome: 'allowed' | 'denied'; + outcome: 'allowed' | 'denied' | 'failed'; + // What went wrong, for a failed request: the api's kind of error, with the provider's HTTP status + // where it answered with one, such as `provider_unreachable 429`; none for every other outcome. + error: string | null; // The web app's agent pages, or the MCP server. via: 'web' | 'mcp'; // The agent's task the request was made for, among the answer's `tasks`; none for entries from @@ -60,7 +63,7 @@ const querySchema = z.object({ agentId: z.string().min(1).optional(), connectionId: z.string().min(1).optional(), taskId: z.string().min(1).optional(), - outcome: z.enum(['allowed', 'denied']).optional(), + outcome: z.enum(['allowed', 'denied', 'failed']).optional(), }); /** diff --git a/packages/api/src/agent-side/tools.test.ts b/packages/api/src/agent-side/tools.test.ts index 77911a8b..a8b61e1b 100644 --- a/packages/api/src/agent-side/tools.test.ts +++ b/packages/api/src/agent-side/tools.test.ts @@ -353,7 +353,7 @@ describe('runAgentTool', () => { expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({action: 'sendNew', recordTitle: 'Friday', outcome: 'allowed'}); }); - test('a failed run is not logged as allowed, and a run that cannot be logged is not returned', async () => { + test('a run the provider fails is logged as failed with what went wrong, not as allowed, and passed on', async () => { useConnection(emailConnection); getAgent.mockResolvedValue(Ok(triage)); connector.get.mockResolvedValue(Ok(inboxEmail)); @@ -362,11 +362,37 @@ describe('runAgentTool', () => { const {runAgentTool} = await import('./tools'); const failed = await runAgentTool(caller, 'mail-1', 'emails_archive', {id: inboxEmail.id}); expect(failed.unwrapErr().kind).toBe('not_found'); - expect(logAgentRequest).not.toHaveBeenCalled(); + expect(logAgentRequest).toHaveBeenCalledTimes(1); + expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({action: 'archive', recordTitle: 'Invoice #20931', outcome: 'failed', error: 'not_found'}); + }); + + test("a failure keeps the provider's HTTP status, and a read that failed before it had a record has no title", async () => { + const tooMany = ApiErr.providerUnreachable({kind: 'http', url: 'https://api.example.com', status: 429, body: 'Slow down, Alex'}); + connector.get.mockResolvedValue(Err(tooMany)); + connector.list.mockResolvedValue(Err(ApiErr.providerUnreachable({kind: 'network', url: 'https://api.example.com', cause: new Error('ECONNRESET')}))); + + const {runAgentTool} = await import('./tools'); + expect((await runAgentTool(caller, 'vault-1', 'entries_get', {id: 'rec-1'})).unwrapErr()).toEqual(tooMany); + expect((await runAgentTool(caller, 'vault-1', 'entries_list', {search: 'passport'})).unwrapErr().kind).toBe('provider_unreachable'); + + expect(logAgentRequest.mock.calls.map(([logged]) => logged)).toMatchObject([ + {action: 'view', recordTitle: null, query: null, outcome: 'failed', error: 'provider_unreachable 429'}, + {action: 'list', recordTitle: null, query: 'passport', outcome: 'failed', error: 'provider_unreachable'}, + ]); + }); + test('a run that cannot be logged is not returned', async () => { + useConnection(emailConnection); + getAgent.mockResolvedValue(Ok(triage)); logAgentRequest.mockResolvedValue(Err(ApiErr.dbError(new Error('boom')))); + + const {runAgentTool} = await import('./tools'); const unlogged = await runAgentTool(caller, 'mail-1', 'emails_list', {}); expect(unlogged.unwrapErr().kind).toBe('db_error'); + + connector.list.mockResolvedValue(Err(ApiErr.credentialsRejected())); + const failedUnlogged = await runAgentTool(caller, 'mail-1', 'emails_list', {}); + expect(failedUnlogged.unwrapErr().kind).toBe('db_error'); }); }); @@ -547,12 +573,13 @@ describe('custom MCP servers', () => { expect(logAgentRequest).toHaveBeenCalledTimes(1); }); - test('what the tool answered with is passed on, and a tool that failed is what the server said', async () => { + test('what the tool answered with is passed on, and a tool that failed is what the server said, logged as failed', async () => { callCustomTool.mockResolvedValue(Err(ApiErr.validationError('query is required'))); const {runAgentTool} = await import('./tools'); const failed = (await runAgentTool(caller, 'mcp-1', 'search_docs', {})).unwrapErr(); expect(failed).toEqual(ApiErr.validationError('query is required')); - expect(logAgentRequest).not.toHaveBeenCalled(); + expect(logAgentRequest).toHaveBeenCalledTimes(1); + expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({collectionId: 'tools', action: 'search_docs', query: null, outcome: 'failed', error: 'validation_error'}); }); }); diff --git a/packages/api/src/agent-side/tools.ts b/packages/api/src/agent-side/tools.ts index d0cee515..417d36c0 100644 --- a/packages/api/src/agent-side/tools.ts +++ b/packages/api/src/agent-side/tools.ts @@ -1,4 +1,5 @@ import {Err, Ok, type Result} from 'ts-results-es'; +import {z} from 'zod'; import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils'; import {getAgent, type AgentRow, type SignedInAgent} from '@proxy/db/agent'; @@ -33,8 +34,8 @@ import {isPlainObject, parseToolParams} from './tool-params'; /** * The agent side, whichever way an agent comes in: the connections it can use, the tools it has * with each, and running one. Every run is made for one of the agent's tasks (tasks.ts), checked - * against the agent's access as it is now and logged with the task, a refused one too; one that - * can't be logged fails rather than go unrecorded. + * against the agent's access as it is now and logged with the task, a refused one too, and one the + * provider fails; one that can't be logged fails rather than go unrecorded. */ export type AgentConnection = { @@ -125,8 +126,10 @@ function recordTitle(collection: Collection, values: RecordValues): string | nul return values[collection.titleField]?.trim() || null; } -// `query` is what a list searched for. -function log(run: ToolRun, outcome: 'allowed' | 'denied', title: string | null, query: string | null = null): Promise> { +type Outcome = 'allowed' | 'denied' | 'failed'; + +// `query` is what a list searched for, and `error` what went wrong, for a failed run. +function log(run: ToolRun, outcome: Outcome, title: string | null, query: string | null = null, error?: string): Promise> { return logAgentRequest({ orgId: run.caller.agent.orgId, agentId: run.caller.agent.agentId, @@ -139,6 +142,28 @@ function log(run: ToolRun, outcome: 'allowed' | 'denied', title: string | null, via: run.caller.via, taskId: run.caller.taskId, startedAt: run.startedAt, + error, + }); +} + +const httpCauseSchema = z.object({kind: z.literal('http'), status: z.number()}); + +// What the activity log keeps of what went wrong: the error's kind, with the provider's HTTP status +// where it answered with one, as `provider_unreachable 429`; never the provider's words, which can +// hold the person's data. +function failureOf(error: ApiError): string { + const cause = 'cause' in error ? httpCauseSchema.safeParse(error.cause) : null; + return cause?.success ? `${error.kind} ${cause.data.status}` : error.kind; +} + +// What a call to the provider answered; one that failed is logged as failed first, with `title` +// and `query` as an allowed one would have been. +function logFailure(run: ToolRun, answer: Result, title: string | null, query: string | null = null): Promise> { + return Do(async ($) => { + if (answer.isErr()) { + $(await log(run, 'failed', title, query, failureOf(answer.error))); + } + return $(answer); }); } @@ -147,7 +172,8 @@ function log(run: ToolRun, outcome: 'allowed' | 'denied', title: string | null, * caller's task, which has to be one of the agent's and still under way; a run without one is * refused, unlogged. A connection outside the organization, or a tool its integration doesn't have, * is not found and not logged; a tool whose action the agent doesn't have is refused and logged as - * denied, saying how to allow it where the connection's sign-in didn't cover the action. + * denied, saying how to allow it where the connection's sign-in didn't cover the action. A run the + * provider fails, turning it down or out of reach, is logged as failed, with what went wrong. * `readOnly` limits the run to tools that only read, or only to those that change something, as * the MCP server's `read` and `run` do; any other is refused, unlogged. */ @@ -221,7 +247,7 @@ function loggedParams(args: Record): string | null { // A custom server's tool, called as it is: its params go to the server as given, which checks // them against its own schema, and its answer comes back as text. Logged by the tool's name, with -// its params, allowed or denied. +// its params, allowed, denied or failed. function runServerTool( caller: TaskCaller, agent: AgentRow, @@ -237,7 +263,7 @@ function runServerTool( return $(Err(ApiErr.validationError(`${tool.name} takes an object of params`))); } const logged = loggedParams(args); - const logRun = (outcome: 'allowed' | 'denied') => + const logRun = (outcome: Outcome, error?: string) => logAgentRequest({ orgId: caller.agent.orgId, agentId: caller.agent.agentId, @@ -250,12 +276,17 @@ function runServerTool( via: caller.via, taskId: caller.taskId, startedAt, + error, }); if (!actionsOf(agent, connection, integration, unavailableActions(connection)).includes(tool.action)) { $(await logRun('denied')); return $(Err(ApiErr.forbidden())); } - const text = $(await callCustomTool(connection, tool.name, args)); + const answer = await callCustomTool(connection, tool.name, args); + if (answer.isErr()) { + $(await logRun('failed', failureOf(answer.error))); + } + const text = $(answer); $(await logRun('allowed')); return {text}; }); @@ -303,7 +334,7 @@ function list(run: ToolRun, params: Record): Promise { const {filterField} = run.target.collection; const query = {search: searchOf(params), page: params.page ?? null, filter: filterField ? (params[filterField] ?? null) : null, parent: params.parent ?? null}; - const page = $(await run.connector.list(run.target, query)); + const page = $(await logFailure(run, await run.connector.list(run.target, query), null, query.search)); $(await log(run, 'allowed', page.trail?.at(-1)?.title ?? null, query.search)); return page; }); @@ -311,7 +342,7 @@ function list(run: ToolRun, params: Record): Promise> { return Do(async ($) => { - const record = $(await run.connector.get(run.target, recordId)); + const record = $(await logFailure(run, await run.connector.get(run.target, recordId), null)); $(await log(run, 'allowed', recordTitle(run.target.collection, record.values))); return {record}; }); @@ -337,7 +368,7 @@ function create(run: ToolRun, params: Record): Promise { const values = $(parseRecordValues(run.target.collection, fieldsOf(params))); $(await requireFieldActions(run, values)); - const record = $(await run.connector.create(run.target, values, params.parent ?? null)); + const record = $(await logFailure(run, await run.connector.create(run.target, values, params.parent ?? null), recordTitle(run.target.collection, values))); $(await log(run, 'allowed', recordTitle(run.target.collection, record.values))); return {record}; }); @@ -348,12 +379,12 @@ function update(run: ToolRun, params: Record): Promise { const {collection} = run.target; const recordId = params.id ?? ''; - const before = $(await run.connector.get(run.target, recordId)); + const before = $(await logFailure(run, await run.connector.get(run.target, recordId), null)); $(requireApplies(run.tool.where, before)); const current = Object.fromEntries(collection.fields.filter((field) => !field.system).map((field) => [field.key, before.values[field.key] ?? ''])); const values = $(parseRecordValues(collection, {...current, ...fieldsOf(params)})); $(await requireFieldActions(run, values, before.values)); - const record = $(await run.connector.update(run.target, recordId, values, before)); + const record = $(await logFailure(run, await run.connector.update(run.target, recordId, values, before), recordTitle(collection, values))); $(await log(run, 'allowed', recordTitle(collection, record.values))); return {record}; }); @@ -361,9 +392,9 @@ function update(run: ToolRun, params: Record): Promise> { return Do(async ($) => { - const before = $(await run.connector.get(run.target, recordId)); + const before = $(await logFailure(run, await run.connector.get(run.target, recordId), null)); $(requireApplies(run.tool.where, before)); - $(await run.connector.remove(run.target, recordId)); + $(await logFailure(run, await run.connector.remove(run.target, recordId), recordTitle(run.target.collection, before.values))); $(await log(run, 'allowed', recordTitle(run.target.collection, before.values))); return {record: null}; }); @@ -372,9 +403,9 @@ function remove(run: ToolRun, recordId: string): Promise> { return Do(async ($) => { const runner = $(requirePresent(run.connector.commands?.[run.tool.operation], ApiErr.notFound('tool', run.tool.name))); - const before = $(await run.connector.get(run.target, recordId)); + const before = $(await logFailure(run, await run.connector.get(run.target, recordId), null)); $(requireApplies(run.tool.where, before)); - const record = $(await runner(run.target, recordId)); + const record = $(await logFailure(run, await runner(run.target, recordId), recordTitle(run.target.collection, before.values))); $(await log(run, 'allowed', recordTitle(run.target.collection, before.values))); return {record}; }); @@ -384,7 +415,7 @@ function newCommand(run: ToolRun, params: Record): Promise { const runner = $(requirePresent(run.connector.newCommands?.[run.tool.operation], ApiErr.notFound('tool', run.tool.name))); const values = $(parseRecordValues(run.target.collection, params)); - const record = $(await runner(run.target, values)); + const record = $(await logFailure(run, await runner(run.target, values), recordTitle(run.target.collection, values))); $(await log(run, 'allowed', recordTitle(run.target.collection, values))); return {record}; }); diff --git a/packages/db/prisma/migrations/v025_add_failed_requests/migration.sql b/packages/db/prisma/migrations/v025_add_failed_requests/migration.sql new file mode 100644 index 00000000..7967bb0e --- /dev/null +++ b/packages/db/prisma/migrations/v025_add_failed_requests/migration.sql @@ -0,0 +1,5 @@ +-- A request the agent was allowed to make that then failed, the provider turning it down or out of +-- reach, is logged as failed, with what went wrong, rather than not at all. +ALTER TYPE "AuditOutcome" ADD VALUE 'failed'; + +ALTER TABLE "AuditEntry" ADD COLUMN "error" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 22766f4f..fb5ec00f 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -275,9 +275,12 @@ model AgentSession { @@index([agentId]) } +// Allowed and done, refused by the agent's access, or allowed but failed: the provider turned it +// down, or couldn't be reached. enum AuditOutcome { allowed denied + failed } // How an agent came in: the web app's agent pages, or the MCP server. @@ -286,9 +289,9 @@ enum AuditVia { mcp } -// One request an agent made, allowed or denied, or one thing Personal Agent Proxy did itself for a -// person, as the home page's widgets write. Plain ids rather than relations, so the entry stays -// when the agent or the connection is gone. +// One request an agent made, allowed, denied or failed, or one thing Personal Agent Proxy did +// itself for a person, as the home page's widgets write. Plain ids rather than relations, so the +// entry stays when the agent or the connection is gone. model AuditEntry { id String @id @default(cuid()) orgId String @@ -311,6 +314,10 @@ model AuditEntry { // every entry logged since this was kept; none for the entries from before, and for what // Personal Agent Proxy did itself. durationMs Int? + // What went wrong, for a failed request: the api's kind of error, such as `credentials_rejected`, + // and the provider's HTTP status after it where it answered with one, such as + // `provider_unreachable 429`. None for every other outcome. + error String? createdAt DateTime @default(now()) diff --git a/packages/db/scripts/seed.ts b/packages/db/scripts/seed.ts index 12065ba1..5247307c 100644 --- a/packages/db/scripts/seed.ts +++ b/packages/db/scripts/seed.ts @@ -542,6 +542,28 @@ function isAllowed(agent: (typeof agents)[number], request: Request): boolean { ); } +// Now and then the service fails an allowed request: too many requests, out of reach, or the +// connection's sign-in turned down, as the api logs what went wrong. +const FAILURES: [string, ...string[]] = [ + 'provider_unreachable 429', + 'provider_unreachable 503', + 'provider_unreachable', + 'credentials_rejected', +]; + +function outcomeOf( + agent: (typeof agents)[number], + request: Request, +): {outcome: 'allowed' | 'denied' | 'failed'; error: string | null} { + if (!isAllowed(agent, request)) { + return {outcome: 'denied', error: null}; + } + if (random() < 0.05) { + return {outcome: 'failed', error: pick(FAILURES)}; + } + return {outcome: 'allowed', error: null}; +} + // What the agents set out to do: every request is made for a task, as the api logs them, a few // requests in a row to each. const TASKS: [{title: string; goal: string}, ...Array<{title: string; goal: string}>] = [ @@ -693,7 +715,7 @@ for (const agent of agents) { action: request.action, recordTitle: onOneRecord ? recordTitle(request.connection, request.collection.id) : null, query: null, - outcome: isAllowed(agent, request) ? 'allowed' : 'denied', + ...outcomeOf(agent, request), via: task.via, taskId: task.id, durationMs: requestDuration(), @@ -743,7 +765,7 @@ for (const agent of agents) { action: request.action, recordTitle: onOneRecord ? recordTitle(request.connection, collectionId) : null, query: searched ? pick(SEARCHES) : null, - outcome: isAllowed(agent, request) ? 'allowed' : 'denied', + ...outcomeOf(agent, request), via, taskId, durationMs: requestDuration(), diff --git a/packages/db/src/audit.ts b/packages/db/src/audit.ts index 5a37ecbd..b7f34251 100644 --- a/packages/db/src/audit.ts +++ b/packages/db/src/audit.ts @@ -23,6 +23,8 @@ export type AuditEntryRow = { // How long the request took, from when it came in to when it was logged; none for entries from // before this was kept, and for what Personal Agent Proxy did itself. durationMs: number | null; + // What went wrong, for a failed request (schema.prisma); none for every other outcome. + error: string | null; createdAt: Date; }; @@ -41,6 +43,8 @@ export async function logAgentRequest(data: { taskId: string | null; // When the request came in, in ms since the epoch, so the entry says how long it took. startedAt: number; + // What went wrong, for a failed request. + error?: string; }): Promise> { const {startedAt, ...entry} = data; return wrapDb(async () => { @@ -71,6 +75,7 @@ export async function logProxyAction(data: { outcome: 'allowed', taskId: null, durationMs: null, + error: null, }, }); }); @@ -78,7 +83,7 @@ export async function logProxyAction(data: { /** * Newest first, optionally only one agent's or one connection's requests, only one task's, or only - * allowed or denied ones; an `agentId` of null is what Personal Agent Proxy did itself. + * allowed, denied or failed ones; an `agentId` of null is what Personal Agent Proxy did itself. */ export async function listAuditEntries( orgId: string, @@ -107,6 +112,7 @@ export async function listAuditEntries( via: true, taskId: true, durationMs: true, + error: true, createdAt: true, }, }), diff --git a/packages/docs/content/agents/mcp.mdx b/packages/docs/content/agents/mcp.mdx index 6a4fb938..7d80c1c1 100644 --- a/packages/docs/content/agents/mcp.mdx +++ b/packages/docs/content/agents/mcp.mdx @@ -6,8 +6,8 @@ description: Add Personal Agent Proxy to Claude, Claude Duet, Claude Code, Claud Personal Agent Proxy is an MCP server. An agent that speaks MCP, such as Claude, Claude Duet, Claude Code, Claude Desktop, ChatGPT, Hermes Agent, Poke or any other MCP client, connects to it, signs in with OAuth, and works as one of your agent logins: it can do what that login is allowed -and nothing more. Successful connection actions and actions denied by access settings are in the -activity log, with the task they were made for. +and nothing more. Successful connection actions, actions denied by access settings and actions the +service failed are in the activity log, with the task they were made for. The server is at your Personal Agent Proxy's address with `/mcp` after it, such as `https://app.personalagentproxy.com/mcp`. Each agent's page in Personal Agent Proxy shows it under @@ -107,8 +107,9 @@ of Personal Agent Proxy's own, which only its login can open. A mailbox over IMA your vault have no such page, so the agent names what it means instead, such as an email by its subject. -An action the login isn't allowed is refused, and logged as denied. The activity log marks the -actions that came in over MCP, each with its task. +An action the login isn't allowed is refused, and logged as denied. One it is allowed that the +service then fails, such as one turned down for too many requests, is logged as failed, with what +went wrong. The activity log marks the actions that came in over MCP, each with its task. ## Disconnect an agent diff --git a/packages/docs/content/connections/custom-mcp.mdx b/packages/docs/content/connections/custom-mcp.mdx index b0df006e..4681e8a3 100644 --- a/packages/docs/content/connections/custom-mcp.mdx +++ b/packages/docs/content/connections/custom-mcp.mdx @@ -4,8 +4,9 @@ description: Connect any MCP server by its address, and allow its tools one by o --- Any service with an MCP server over HTTP can be connected by its address. The server's tools become -what agents can be allowed with it, each on its own. Successful tool calls, and calls denied by -access settings, are written to the activity log, as with the built-in integrations. +what agents can be allowed with it, each on its own. Successful tool calls, calls denied by access +settings, and calls the server failed are written to the activity log, as with the built-in +integrations. ## Connect a server diff --git a/packages/docs/content/index.mdx b/packages/docs/content/index.mdx index 5bfaa847..9529428d 100644 --- a/packages/docs/content/index.mdx +++ b/packages/docs/content/index.mdx @@ -6,8 +6,8 @@ description: What Personal Agent Proxy is, and what you run to host it yourself. Personal Agent Proxy holds the integrations and personal information an AI agent needs, so every agent company does not have to rebuild them and you do not have to trust each one's security. You connect your services and hand out agent logins; an agent signs in with one and works through what -it was given. Successful connection actions, and actions denied by the login's access settings, are -written to the activity log. +it was given. Successful connection actions, actions denied by the login's access settings, and +actions the service failed are written to the activity log. On the home page, [quick actions](/quick-actions) find people, email and files, work with calendars, write email, calculate, convert units and show balances or spending from the services you connect. diff --git a/packages/web/src/client/agents-client.ts b/packages/web/src/client/agents-client.ts index c76249a9..92cf093a 100644 --- a/packages/web/src/client/agents-client.ts +++ b/packages/web/src/client/agents-client.ts @@ -68,7 +68,7 @@ export type ActivityFilter = { connectionId?: string; // One task's requests. taskId?: string; - outcome?: 'allowed' | 'denied'; + outcome?: 'allowed' | 'denied' | 'failed'; }; export function listActivity(filter: ActivityFilter) { diff --git a/packages/web/src/client/schemas.ts b/packages/web/src/client/schemas.ts index 92777644..415229fe 100644 --- a/packages/web/src/client/schemas.ts +++ b/packages/web/src/client/schemas.ts @@ -52,7 +52,10 @@ export const auditEntrySchema = z.object({ action: z.string(), recordTitle: z.string().nullable(), query: z.string().nullable(), - outcome: z.enum(['allowed', 'denied']), + outcome: z.enum(['allowed', 'denied', 'failed']), + // What went wrong, for a failed request: the api's kind of error, with the provider's HTTP + // status where it answered with one, such as `provider_unreachable 429`. + error: z.string().nullable(), // The web app's agent pages, or the MCP server. via: z.enum(['web', 'mcp']), // The agent's task it was made for, among the answer's tasks; none for entries from before diff --git a/packages/web/src/components/audit-entry-dialog.tsx b/packages/web/src/components/audit-entry-dialog.tsx index 564a6f3c..0c70d4d5 100644 --- a/packages/web/src/components/audit-entry-dialog.tsx +++ b/packages/web/src/components/audit-entry-dialog.tsx @@ -10,7 +10,7 @@ import { DialogTitle, } from '@proxy/ui/components/dialog'; import {AgentLogo, IntegrationLogo, ProxyLogo} from '@/components/brand-logo'; -import {describeEntry, entryAction, locateEntry} from '@/lib/access'; +import {describeEntry, describeFailure, entryAction, locateEntry} from '@/lib/access'; import type {ActivityTask, AgentLogin, AuditEntry, Connection} from '@/lib/types'; type Props = { @@ -81,7 +81,6 @@ function AuditEntryContent({ const task = tasks.find((candidate) => candidate.id === entry.taskId); const located = locateEntry(connections, entry); const action = entryAction(entry, located); - const denied = entry.outcome === 'denied'; const toolRun = located?.integration.custom || entry.collectionId === TOOLS_COLLECTION_ID; return ( @@ -94,12 +93,15 @@ function AuditEntryContent({
- {denied ? ( - Denied - ) : ( - Allowed - )} + {entry.outcome === 'allowed' && Allowed} + {entry.outcome === 'denied' && Denied} + {entry.outcome === 'failed' && Failed} + {entry.outcome === 'failed' && ( + + {describeFailure(entry.error)} + + )} {entry.agentId === null && ( <> diff --git a/packages/web/src/components/audit-list.test.tsx b/packages/web/src/components/audit-list.test.tsx index 78000a6c..7f015d26 100644 --- a/packages/web/src/components/audit-list.test.tsx +++ b/packages/web/src/components/audit-list.test.tsx @@ -53,31 +53,36 @@ const entry: AuditEntry = { recordTitle: 'Weekly digest', query: null, outcome: 'denied', + error: null, via: 'mcp', taskId: task.id, }; +function renderList(entries: AuditEntry[]) { + const router = createMemoryRouter( + [ + { + path: '/activity', + element: ( + + + + ), + }, + ], + {initialEntries: ['/activity']}, + ); + render(); +} + describe('AuditList', () => { test('opens an entry in full from its row', async () => { - const router = createMemoryRouter( - [ - { - path: '/activity', - element: ( - - - - ), - }, - ], - {initialEntries: ['/activity']}, - ); - render(); + renderList([entry]); const row = await screen.findByRole('button', {name: /Tried to archive email/}); expect(row.textContent).toContain('· for Triage inbox'); @@ -97,4 +102,18 @@ describe('AuditList', () => { expect(within(dialog).getByText('Archive')).toBeDefined(); expect(within(dialog).getByText('MCP')).toBeDefined(); }); + + test('a failed request says so, and what went wrong', async () => { + renderList([{...entry, outcome: 'failed', error: 'provider_unreachable 429'}]); + + fireEvent.click( + await screen.findByRole('button', {name: /Failed to archive email “Weekly digest”/}), + ); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Failed')).toBeDefined(); + expect( + within(dialog).getByText('The service turned it down for too many requests (HTTP 429)'), + ).toBeDefined(); + }); }); diff --git a/packages/web/src/components/audit-list.tsx b/packages/web/src/components/audit-list.tsx index 13fd1cdd..5624c693 100644 --- a/packages/web/src/components/audit-list.tsx +++ b/packages/web/src/components/audit-list.tsx @@ -83,7 +83,7 @@ export function AuditList({ const agent = agents.find((candidate) => candidate.id === entry.agentId); const located = locateEntry(connections, entry); const integration = located?.integration ?? null; - const denied = entry.outcome === 'denied'; + const refusedOrFailed = entry.outcome !== 'allowed'; const task = tasks.find((candidate) => candidate.id === entry.taskId); return (
  • @@ -94,7 +94,9 @@ export function AuditList({ > {formatDateTime(entry.at)} {showAgent && } - + {describeEntry(entry, located)} {task && · for {task.title}} {entry.via === 'mcp' && ( diff --git a/packages/web/src/lib/access.ts b/packages/web/src/lib/access.ts index d192d3e3..65ce9fe2 100644 --- a/packages/web/src/lib/access.ts +++ b/packages/web/src/lib/access.ts @@ -139,19 +139,24 @@ const ACTION_VERBS: Partial> = { }; // A custom MCP server's tool, run as it is: "Ran Search docs with {"query":"invoices"}", "Tried to -// run Search docs"; by the tool's name once the server is disconnected. +// run Search docs", "Failed to run Search docs"; by the tool's name once the server is +// disconnected. function describeToolRun(entry: AuditEntry, integration: Integration | undefined): string { const title = integration?.actions.find((action) => action.id === entry.action)?.label ?? entry.action; if (entry.outcome === 'denied') { return `Tried to run ${title}`; } + if (entry.outcome === 'failed') { + return `Failed to run ${title}`; + } return entry.query ? `Ran ${title} with ${entry.query}` : `Ran ${title}`; } // "Viewed email “Thursday sync moved?”", "Listed Entries", "Searched Email for “invoice”", -// "Listed Pages in “Companies”", "Tried to list Contacts", and a command's own words: "Sent email -// “Re: Q3”", "Tried to send email". A list's record title is the record it was opened in. +// "Listed Pages in “Companies”", "Tried to list Contacts", "Failed to view issue", and a command's +// own words: "Sent email “Re: Q3”", "Tried to send email", "Failed to send email “Re: Q3”". A list's +// record title is the record it was opened in. export function describeEntry(entry: AuditEntry, located: LocatedEntry | null): string { if (located?.integration.custom || entry.collectionId === TOOLS_COLLECTION_ID) { return describeToolRun(entry, located?.integration); @@ -166,23 +171,54 @@ export function describeEntry(entry: AuditEntry, located: LocatedEntry | null): if (denied) { return `Tried to ${what}`; } + if (entry.outcome === 'failed') { + return `Failed to ${what}`; + } return entry.query ? `Searched ${where} for “${entry.query}”` : `Listed ${where}`; } const record = entry.recordTitle ? `${singular} “${entry.recordTitle}”` : singular; const command = collection?.commands?.find((candidate) => candidate.id === entry.action); + if (command && denied) { + return `Tried to ${command.tried.replace('{}', singular)}`; + } + if (command && entry.outcome === 'failed') { + return `Failed to ${command.tried.replace('{}', record)}`; + } if (command) { - return denied - ? `Tried to ${command.tried.replace('{}', singular)}` - : command.done.replace('{}', record); + return command.done.replace('{}', record); } if (denied) { return `Tried to ${entry.action} ${singular}`; } + if (entry.outcome === 'failed') { + return `Failed to ${entry.action} ${record}`; + } return `${ACTION_VERBS[entry.action] ?? entry.action} ${record}`; } +const FAILURES: Partial> = { + credentials_rejected: 'The service turned the connection’s sign-in down', + provider_unreachable: 'The service couldn’t be reached', + not_found: 'The service didn’t find it', + validation_error: 'The service turned the request down', + conflict: 'The service turned the request down', + unavailable: 'The service turned the request down', +}; + +// What went wrong with a failed request, from the kind of error the api logged and the service's +// HTTP status where it answered with one: "The service couldn’t be reached (HTTP 503)", and too +// many requests in words of their own. +export function describeFailure(error: string | null): string { + const [kind = '', status] = (error ?? '').split(' '); + if (status === '429') { + return 'The service turned it down for too many requests (HTTP 429)'; + } + const words = FAILURES[kind] ?? 'Something went wrong'; + return status ? `${words} (HTTP ${status})` : words; +} + // The action the request needed: a custom server's tool is its own, anything else the one its // collection names for it. Null once the connection is gone. export function entryAction(entry: AuditEntry, located: LocatedEntry | null): Action | null { diff --git a/packages/web/src/loaders.ts b/packages/web/src/loaders.ts index 97335ebc..5253e452 100644 --- a/packages/web/src/loaders.ts +++ b/packages/web/src/loaders.ts @@ -120,7 +120,8 @@ export async function activityLoader({request}: {request: Request}) { agentId: params.get('agent') ?? undefined, connectionId: params.get('connection') ?? undefined, taskId: params.get('task') ?? undefined, - outcome: outcome === 'allowed' || outcome === 'denied' ? outcome : undefined, + outcome: + outcome === 'allowed' || outcome === 'denied' || outcome === 'failed' ? outcome : undefined, }; const [activity, connections, agents] = await Promise.all([ listActivity(filter), diff --git a/packages/web/src/pages/activity-page.tsx b/packages/web/src/pages/activity-page.tsx index 35be8018..b1a63c0e 100644 --- a/packages/web/src/pages/activity-page.tsx +++ b/packages/web/src/pages/activity-page.tsx @@ -27,14 +27,15 @@ const OUTCOMES: Option[] = [ {value: ALL, label: 'All outcomes'}, {value: 'allowed', label: 'Allowed'}, {value: 'denied', label: 'Blocked'}, + {value: 'failed', label: 'Failed'}, ]; // Every request an agent made, and what Personal Agent Proxy did itself from the home page, // newest first, narrowed by agent, connection and outcome, and to one agent's task. The filters // live in the URL, so the agent and connection pages, the home page's lanes and an entry's task can // link straight to their part of the log, and the api applies them (`?agent=proxy` for Personal -// Agent Proxy's own, `?outcome=denied` for what was refused, `?task=` for the requests made for one -// task). +// Agent Proxy's own, `?outcome=denied` for what was refused, `?outcome=failed` for what the provider +// failed, `?task=` for the requests made for one task). export function ActivityPage() { const { entries,