diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index 21a7e6b0637..e2725c5cb50 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -16,7 +16,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand. | [public.agent_eval_rating](public.agent_eval_rating.md) | 8 | | BASE TABLE | | [public.agent_eval_result](public.agent_eval_result.md) | 15 | | BASE TABLE | | [public.agent_eval_run](public.agent_eval_run.md) | 14 | | BASE TABLE | -| [public.agent_execution](public.agent_execution.md) | 20 | | BASE TABLE | +| [public.agent_execution](public.agent_execution.md) | 21 | | BASE TABLE | | [public.agent_execution_threads](public.agent_execution_threads.md) | 17 | | BASE TABLE | | [public.agent_files](public.agent_files.md) | 10 | | BASE TABLE | | [public.agent_history](public.agent_history.md) | 9 | | BASE TABLE | @@ -441,6 +441,7 @@ erDiagram timestamp_3__with_time_zone createdAt integer duration text error + json failureSummary varchar_16_ hitlStatus varchar_36_ id varchar_255_ model diff --git a/docs/generated/postgres-schema/public.agent_execution.md b/docs/generated/postgres-schema/public.agent_execution.md index 2b1c029cb19..22de43f5274 100644 --- a/docs/generated/postgres-schema/public.agent_execution.md +++ b/docs/generated/postgres-schema/public.agent_execution.md @@ -10,6 +10,7 @@ | createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | | duration | integer | 0 | false | | | | | error | text | | true | | | | +| failureSummary | json | | true | | | Execution failure projection as {count, latest} for session list queries | | hitlStatus | varchar(16) | | true | | | | | id | varchar(36) | | false | | | | | model | varchar(255) | | true | | | | @@ -64,6 +65,7 @@ erDiagram timestamp_3__with_time_zone createdAt integer duration text error + json failureSummary varchar_16_ hitlStatus varchar_36_ id varchar_255_ model diff --git a/docs/generated/postgres-schema/public.agent_execution_threads.md b/docs/generated/postgres-schema/public.agent_execution_threads.md index 64ff2981ec7..6620c62be33 100644 --- a/docs/generated/postgres-schema/public.agent_execution_threads.md +++ b/docs/generated/postgres-schema/public.agent_execution_threads.md @@ -103,6 +103,7 @@ erDiagram timestamp_3__with_time_zone createdAt integer duration text error + json failureSummary varchar_16_ hitlStatus varchar_36_ id varchar_255_ model diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 68e7f15f439..eacec929925 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -16,7 +16,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [agent_eval_rating](agent_eval_rating.md) | 8 | | table | | [agent_eval_result](agent_eval_result.md) | 15 | | table | | [agent_eval_run](agent_eval_run.md) | 14 | | table | -| [agent_execution](agent_execution.md) | 20 | | table | +| [agent_execution](agent_execution.md) | 21 | | table | | [agent_execution_threads](agent_execution_threads.md) | 17 | | table | | [agent_files](agent_files.md) | 10 | | table | | [agent_history](agent_history.md) | 9 | | table | @@ -428,6 +428,7 @@ erDiagram datetime_3_ createdAt INTEGER duration TEXT error + TEXT failureSummary varchar_16_ hitlStatus varchar_36_ id PK varchar_255_ model diff --git a/docs/generated/sqlite-schema/agent_execution.md b/docs/generated/sqlite-schema/agent_execution.md index 3f346494d9f..8b503a5fdc9 100644 --- a/docs/generated/sqlite-schema/agent_execution.md +++ b/docs/generated/sqlite-schema/agent_execution.md @@ -6,7 +6,7 @@ Table Definition ```sql -CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "startedAt" datetime(3), "stoppedAt" datetime(3), "duration" integer NOT NULL DEFAULT (0), "userMessage" text, "model" varchar(255), "promptTokens" integer, "completionTokens" integer, "totalTokens" integer, "cost" real, "timeline" text, "error" text, "hitlStatus" varchar(16), "source" varchar(32), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "storedAt" varchar(2) NOT NULL DEFAULT ('db'), "attachments" text, CONSTRAINT "CHK_agent_execution_storedAt" CHECK (("storedAt" IN ('db', 'fs', 's3', 'az'))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK ((((("hitlStatus" IN ('suspended', 'resumed')))))), CONSTRAINT "CHK_agent_execution_status" CHECK ("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted')), CONSTRAINT "FK_add2432fb6034cc18b6af299dce" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION) +CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "startedAt" datetime(3), "stoppedAt" datetime(3), "duration" integer NOT NULL DEFAULT (0), "userMessage" text, "model" varchar(255), "promptTokens" integer, "completionTokens" integer, "totalTokens" integer, "cost" real, "timeline" text, "error" text, "hitlStatus" varchar(16), "source" varchar(32), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "storedAt" varchar(2) NOT NULL DEFAULT ('db'), "attachments" text, "failureSummary" text, CONSTRAINT "CHK_agent_execution_storedAt" CHECK ((("storedAt" IN ('db', 'fs', 's3', 'az')))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK (((((("hitlStatus" IN ('suspended', 'resumed'))))))), CONSTRAINT "CHK_agent_execution_status" CHECK (("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted'))), CONSTRAINT "FK_add2432fb6034cc18b6af299dce" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION) ``` @@ -21,6 +21,7 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId | createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | | duration | INTEGER | 0 | false | | | | | error | TEXT | | true | | | | +| failureSummary | TEXT | | true | | | | | hitlStatus | varchar(16) | | true | | | | | id | varchar(36) | | false | | | | | model | varchar(255) | | true | | | | @@ -40,9 +41,9 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId | Name | Type | Definition | | ---- | ---- | ---------- | -| - | CHECK | CHECK (("storedAt" IN ('db', 'fs', 's3', 'az'))) | -| - | CHECK | CHECK ((((("hitlStatus" IN ('suspended', 'resumed')))))) | -| - | CHECK | CHECK ("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted')) | +| - | CHECK | CHECK ((("storedAt" IN ('db', 'fs', 's3', 'az')))) | +| - | CHECK | CHECK (((((("hitlStatus" IN ('suspended', 'resumed'))))))) | +| - | CHECK | CHECK (("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted'))) | | - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | | id | PRIMARY KEY | PRIMARY KEY (id) | | sqlite_autoindex_agent_execution_1 | PRIMARY KEY | PRIMARY KEY (id) | @@ -69,6 +70,7 @@ erDiagram datetime_3_ createdAt INTEGER duration TEXT error + TEXT failureSummary varchar_16_ hitlStatus varchar_36_ id PK varchar_255_ model diff --git a/docs/generated/sqlite-schema/agent_execution_threads.md b/docs/generated/sqlite-schema/agent_execution_threads.md index ce95e395411..df071e4311f 100644 --- a/docs/generated/sqlite-schema/agent_execution_threads.md +++ b/docs/generated/sqlite-schema/agent_execution_threads.md @@ -104,6 +104,7 @@ erDiagram datetime_3_ createdAt INTEGER duration TEXT error + TEXT failureSummary varchar_16_ hitlStatus varchar_36_ id PK varchar_255_ model diff --git a/packages/@n8n/db/src/migrations/common/1787040021605-AddAgentExecutionFailureSummary.ts b/packages/@n8n/db/src/migrations/common/1787040021605-AddAgentExecutionFailureSummary.ts new file mode 100644 index 00000000000..b223596de9e --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1787040021605-AddAgentExecutionFailureSummary.ts @@ -0,0 +1,19 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +export class AddAgentExecutionFailureSummary1787040021605 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns( + 'agent_execution', + [ + column('failureSummary').json.comment( + 'Execution failure projection as {count, latest} for session list queries', + ), + ], + { recreatesOnSqlite: true }, + ); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns('agent_execution', ['failureSummary'], { recreatesOnSqlite: true }); + } +} diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts index 3a78ffc11ab..db298e8e76b 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts @@ -294,7 +294,7 @@ describe('AgentExecutionService', () => { output: {}, startTime: 0, endTime: 123, - success: true, + success: false, }, ], }); @@ -316,7 +316,19 @@ describe('AgentExecutionService', () => { expect(agentExecutionRepository.updateIfRunning).toHaveBeenCalledWith( 'execution-1', - expect.objectContaining({ timeline: null, storedAt: 'fs' }), + expect.objectContaining({ + timeline: null, + storedAt: 'fs', + failureSummary: { + count: 1, + latest: { + kind: 'tool', + name: 'lookup', + message: null, + occurredAt: 123, + }, + }, + }), ); expect(agentExecutionLogStore.write).toHaveBeenCalledWith( { agentId: 'agent-1', threadId: 'thread-1', executionId: 'execution-1' }, @@ -385,6 +397,7 @@ describe('AgentExecutionService', () => { status: 'success', timeline: record.timeline, storedAt: 'db', + failureSummary: null, }), ); }); @@ -738,6 +751,7 @@ describe('AgentExecutionService', () => { status: 'cancelled', timeline: record.timeline, storedAt: 'db', + failureSummary: null, }), ); expect(telemetry.trackAgentTurnFinished).toHaveBeenCalledWith( @@ -786,12 +800,65 @@ describe('AgentExecutionService', () => { timeline: partial, storedAt: 'db', error: expect.stringContaining('interrupted'), + failureSummary: { + count: 1, + latest: { + kind: 'execution', + name: null, + message: expect.stringContaining('interrupted'), + occurredAt: expect.any(Number), + }, + }, }), ); expect(agentExecutionLogStore.write).not.toHaveBeenCalled(); }); }); + describe('getThreads', () => { + it('returns latest run statuses with recovered failures marked as errors', async () => { + const failedThread = makeThread({ id: 'thread-failed' }); + const cleanThread = makeThread({ id: 'thread-clean' }); + const runningThread = makeThread({ id: 'thread-running' }); + const emptyThread = makeThread({ id: 'thread-empty' }); + const failureSummary = { + count: 2, + latest: { + kind: 'tool' as const, + name: 'lookup', + message: 'request failed', + occurredAt: 20, + executionId: 'execution-2', + }, + }; + agentExecutionThreadRepository.findByProjectIdPaginated.mockResolvedValue({ + threads: [failedThread, cleanThread, runningThread, emptyThread], + nextCursor: null, + }); + agentExecutionRepository.findFirstUserMessageByThreadIds.mockResolvedValue(new Map()); + agentExecutionRepository.findFirstSourceByThreadIds.mockResolvedValue(new Map()); + agentExecutionRepository.findFailureSummariesByThreadIds.mockResolvedValue( + new Map([[failedThread.id, failureSummary]]), + ); + agentExecutionRepository.findLatestStatusesByThreadIds.mockResolvedValue( + new Map([ + [failedThread.id, 'success'], + [cleanThread.id, 'success'], + [runningThread.id, 'running'], + ]), + ); + + const result = await service.getThreads('project-1', 'agent-1', 20); + + expect(result.threads).toEqual([ + expect.objectContaining({ id: failedThread.id, failureSummary, status: 'error' }), + expect.objectContaining({ id: cleanThread.id, failureSummary: null, status: 'success' }), + expect.objectContaining({ id: runningThread.id, failureSummary: null, status: 'running' }), + expect.objectContaining({ id: emptyThread.id, failureSummary: null, status: null }), + ]); + }); + }); + describe('getThreadDetail', () => { it('returns thread executions after ownership validation', async () => { const thread = makeThread(); diff --git a/packages/cli/src/modules/agents/__tests__/execution-failure-summary.test.ts b/packages/cli/src/modules/agents/__tests__/execution-failure-summary.test.ts new file mode 100644 index 00000000000..2fc0ac1a97a --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/execution-failure-summary.test.ts @@ -0,0 +1,125 @@ +import type { TimelineEvent } from '../execution-recorder'; +import { computeExecutionFailureSummary } from '../utils/execution-failure-summary'; + +function failedCall( + kind: 'tool' | 'node' | 'workflow', + overrides: Partial> = {}, +): Extract { + return { + type: 'tool-call', + kind, + name: 'fallback_name', + toolCallId: 'call-1', + input: {}, + output: { error: 'request failed' }, + startTime: 10, + endTime: 20, + success: false, + ...overrides, + }; +} + +describe('computeExecutionFailureSummary', () => { + it.each([ + ['tool', {}, 'fallback_name', 'request failed'], + ['node', { nodeDisplayName: 'Lookup customer' }, 'Lookup customer', 'request failed'], + ['workflow', { workflowName: 'Enrich account' }, 'Enrich account', 'request failed'], + [ + 'tool', + { + name: 'delegate_subagent', + success: true, + output: { status: 'failed', error: 'child failed' }, + }, + 'delegate_subagent', + 'child failed', + ], + ] as const)( + 'projects a completed failed %s call', + (kind, overrides, expectedName, expectedMessage) => { + const summary = computeExecutionFailureSummary({ + timeline: [failedCall(kind, overrides)], + status: 'success', + error: null, + stoppedAt: 30, + }); + + expect(summary).toEqual({ + count: 1, + latest: { + kind, + name: expectedName, + message: expectedMessage, + occurredAt: 20, + }, + }); + }, + ); + + it('counts workflow and execution failure scopes once and keeps the latest truncated message', () => { + const summary = computeExecutionFailureSummary({ + timeline: [ + failedCall('workflow', { + success: true, + output: { status: 'error', error: 'workflow failed' }, + }), + ], + status: 'error', + error: 'x'.repeat(500), + stoppedAt: 30, + }); + + expect(summary).toEqual({ + count: 2, + latest: { + kind: 'execution', + name: null, + message: 'x'.repeat(400), + occurredAt: 30, + }, + }); + }); + + it.each([ + { + name: 'open call', + timeline: [failedCall('tool', { endTime: 0 })], + status: 'success' as const, + }, + { + name: 'declined call', + timeline: [failedCall('node', { output: { declined: true, error: 'not approved' } })], + status: 'success' as const, + }, + { name: 'cancelled execution', timeline: [], status: 'cancelled' as const }, + { name: 'clean success', timeline: [], status: 'success' as const }, + ])('does not project a $name', ({ timeline, status }) => { + expect( + computeExecutionFailureSummary({ + timeline, + status, + error: null, + stoppedAt: 30, + }), + ).toBeNull(); + }); + + it('projects interrupted executions as failures', () => { + expect( + computeExecutionFailureSummary({ + timeline: [], + status: 'interrupted', + error: 'Agent execution was interrupted.', + stoppedAt: 30, + }), + ).toEqual({ + count: 1, + latest: { + kind: 'execution', + name: null, + message: 'Agent execution was interrupted.', + occurredAt: 30, + }, + }); + }); +}); diff --git a/packages/cli/src/modules/agents/agent-execution.service.ts b/packages/cli/src/modules/agents/agent-execution.service.ts index 4fa1c4f2bad..5876994b301 100644 --- a/packages/cli/src/modules/agents/agent-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-execution.service.ts @@ -14,7 +14,7 @@ import { } from './agent-chat-attachment.service'; import { AgentExecutionUpdateBroadcaster } from './agent-execution-update-broadcaster'; import { AgentExecutionThread } from './entities/agent-execution-thread.entity'; -import { AgentExecution } from './entities/agent-execution.entity'; +import { AgentExecution, type AgentExecutionStatus } from './entities/agent-execution.entity'; import type { MessageRecord, TimelineEvent } from './execution-recorder'; import { AgentExecutionLogStore } from './execution-log/agent-execution-log-store'; import { N8nMemory } from './integrations/n8n-memory'; @@ -24,6 +24,10 @@ import { AgentExecutionRepository, type RunningAgentExecution, } from './repositories/agent-execution.repository'; +import { + computeExecutionFailureSummary, + type ThreadFailureSummary, +} from './utils/execution-failure-summary'; export interface RecordMessageParams { threadId: string; @@ -70,6 +74,8 @@ export interface ThreadListItem extends Omit { const { record, hitlStatus } = params; const status = executionStatus(record); + const stoppedAt = new Date(record.startTime + record.duration); + const failureSummary = computeExecutionFailureSummary({ + timeline: record.timeline, + status, + error: record.error, + stoppedAt: stoppedAt.getTime(), + }); let storedAt: AgentExecution['storedAt'] = record.timeline.length > 0 ? this.storageConfig.modeTag : 'db'; @@ -166,7 +180,7 @@ export class AgentExecutionService { const finalized = await this.agentExecutionRepository.updateIfRunning(executionId, { status, - stoppedAt: new Date(record.startTime + record.duration), + stoppedAt, duration: record.duration, model: record.model, promptTokens: record.usage?.promptTokens ?? null, @@ -176,6 +190,7 @@ export class AgentExecutionService { timeline: storedAt === 'db' && record.timeline.length > 0 ? record.timeline : null, storedAt, error: record.error, + failureSummary, hitlStatus: hitlStatus ?? null, }); if (!finalized) return executionId; @@ -200,6 +215,7 @@ export class AgentExecutionService { async finalizeInterruptedExecution(execution: RunningAgentExecution): Promise { const timeline = execution.timeline ?? []; const stoppedAt = new Date(); + const error = 'Agent execution was interrupted by a process restart.'; const duration = execution.startedAt ? Math.max(0, stoppedAt.getTime() - execution.startedAt.getTime()) : 0; @@ -209,7 +225,13 @@ export class AgentExecutionService { duration, timeline: timeline.length > 0 ? timeline : null, storedAt: 'db', - error: 'Agent execution was interrupted by a process restart.', + error, + failureSummary: computeExecutionFailureSummary({ + timeline, + status: 'interrupted', + error, + stoppedAt: stoppedAt.getTime(), + }), }); if (finalized) void this.notifyInterruptedExecution(execution); return finalized; @@ -494,9 +516,11 @@ export class AgentExecutionService { } const threadIds = page.threads.map((t) => t.id); - const [messageMap, sourceMap] = await Promise.all([ + const [messageMap, sourceMap, failureSummaryMap, latestStatusMap] = await Promise.all([ this.agentExecutionRepository.findFirstUserMessageByThreadIds(threadIds), this.agentExecutionRepository.findFirstSourceByThreadIds(threadIds), + this.agentExecutionRepository.findFailureSummariesByThreadIds(threadIds), + this.agentExecutionRepository.findLatestStatusesByThreadIds(threadIds), ]); return { @@ -505,6 +529,8 @@ export class AgentExecutionService { ...t, firstMessage: messageMap.get(t.id) ?? null, source: sourceMap.get(t.id) ?? null, + failureSummary: failureSummaryMap.get(t.id) ?? null, + status: sessionStatus(latestStatusMap.get(t.id), failureSummaryMap.has(t.id)), })), }; } @@ -584,6 +610,14 @@ export class AgentExecutionService { } } +function sessionStatus( + latestStatus: AgentExecutionStatus | undefined, + hasFailureSummary: boolean, +): AgentExecutionStatus | null { + if (!latestStatus) return null; + return latestStatus === 'success' && hasFailureSummary ? 'error' : latestStatus; +} + function cleanUserMessage(message: string | null, agentName: string): string | null { if (message === null) return null; const cleaned = message diff --git a/packages/cli/src/modules/agents/entities/agent-execution.entity.ts b/packages/cli/src/modules/agents/entities/agent-execution.entity.ts index 7c852ceca80..bcd534446c2 100644 --- a/packages/cli/src/modules/agents/entities/agent-execution.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-execution.entity.ts @@ -8,6 +8,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from '@n8n/typeorm'; import { AgentExecutionThread } from './agent-execution-thread.entity'; import type { TimelineEvent } from '../execution-recorder'; +import type { AgentExecutionFailureSummary } from '../utils/execution-failure-summary'; export type AgentExecutionStatus = 'running' | 'success' | 'error' | 'cancelled' | 'interrupted'; export type AgentExecutionHitlStatus = 'suspended' | 'resumed'; @@ -84,6 +85,9 @@ export class AgentExecution extends WithTimestampsAndStringId { @Column({ type: 'text', nullable: true }) error: string | null; + @JsonColumn({ nullable: true }) + failureSummary: AgentExecutionFailureSummary | null; + @Column({ type: 'varchar', length: 16, nullable: true }) hitlStatus: AgentExecutionHitlStatus | null; diff --git a/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts b/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts index f872ba7073f..400be84c28f 100644 --- a/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts @@ -2,7 +2,8 @@ import { Service } from '@n8n/di'; import { DataSource, IsNull, Not, Repository } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; -import { AgentExecution } from '../entities/agent-execution.entity'; +import { AgentExecution, type AgentExecutionStatus } from '../entities/agent-execution.entity'; +import type { ThreadFailureSummary } from '../utils/execution-failure-summary'; export type RunningAgentExecution = Pick< AgentExecution, @@ -11,7 +12,7 @@ export type RunningAgentExecution = Pick< type AgentExecutionFinalizationValues = Pick< AgentExecution, - 'status' | 'stoppedAt' | 'duration' | 'timeline' | 'storedAt' | 'error' + 'status' | 'stoppedAt' | 'duration' | 'timeline' | 'storedAt' | 'error' | 'failureSummary' > & Partial< Pick< @@ -122,6 +123,55 @@ export class AgentExecutionRepository extends Repository { return new Map(rows.map((r) => [r.threadId, r.source])); } + async findLatestStatusesByThreadIds( + threadIds: string[], + ): Promise> { + if (threadIds.length === 0) return new Map(); + + const tableName = this.metadata.tablePath; + const rows = await this.createQueryBuilder('e') + .select(['e."threadId" AS "threadId"', 'e."status" AS "status"']) + .where('e."threadId" IN (:...threadIds)', { threadIds }) + .andWhere( + `e.id = (SELECT e2.id FROM ${tableName} e2 ` + + 'WHERE e2."threadId" = e."threadId" ' + + 'ORDER BY e2."createdAt" DESC, e2.id DESC LIMIT 1)', + ) + .getRawMany<{ threadId: string; status: AgentExecutionStatus }>(); + + return new Map(rows.map((row) => [row.threadId, row.status])); + } + + async findFailureSummariesByThreadIds( + threadIds: string[], + ): Promise> { + if (threadIds.length === 0) return new Map(); + + const executions = await this.createQueryBuilder('e') + .select(['e.id', 'e.threadId', 'e.failureSummary']) + .where('e."threadId" IN (:...threadIds)', { threadIds }) + .andWhere('e."failureSummary" IS NOT NULL') + .getMany(); + const summaries = new Map(); + + for (const execution of executions) { + const summary = execution.failureSummary; + if (!summary) continue; + + const latest = { ...summary.latest, executionId: execution.id }; + const current = summaries.get(execution.threadId); + if (!current) { + summaries.set(execution.threadId, { count: summary.count, latest }); + continue; + } + + current.count += summary.count; + if (latest.occurredAt >= current.latest.occurredAt) current.latest = latest; + } + + return summaries; + } + /** * Suspended runs in a thread that don't yet have a `model` recorded. * Used by the resume-completion path to backfill model info, which only diff --git a/packages/cli/src/modules/agents/utils/execution-failure-summary.ts b/packages/cli/src/modules/agents/utils/execution-failure-summary.ts new file mode 100644 index 00000000000..a1e9267ff66 --- /dev/null +++ b/packages/cli/src/modules/agents/utils/execution-failure-summary.ts @@ -0,0 +1,91 @@ +import { isRecord } from '@n8n/utils/is-record'; + +import type { TimelineEvent } from '../execution-recorder'; + +export type AgentExecutionFailureKind = 'execution' | 'tool' | 'node' | 'workflow'; + +export interface AgentExecutionFailure { + kind: AgentExecutionFailureKind; + name: string | null; + message: string | null; + occurredAt: number; +} + +export interface AgentExecutionFailureSummary { + count: number; + latest: AgentExecutionFailure; +} + +export interface ThreadFailureSummary extends AgentExecutionFailureSummary { + latest: AgentExecutionFailure & { executionId: string }; +} + +const MAX_FAILURE_MESSAGE_LENGTH = 400; + +function failureMessage(output: unknown): string | null { + if (!isRecord(output) || typeof output.error !== 'string') return null; + const message = output.error.trim(); + return message ? message.slice(0, MAX_FAILURE_MESSAGE_LENGTH) : null; +} + +function isDeclinedToolOutput(output: unknown): boolean { + return isRecord(output) && output.declined === true; +} + +function isSoftFailure(event: Extract): boolean { + if (!isRecord(event.output)) return false; + return ( + (event.kind === 'workflow' && event.output.status === 'error') || + (event.name === 'delegate_subagent' && event.output.status === 'failed') + ); +} + +export function computeExecutionFailureSummary({ + timeline, + status, + error, + stoppedAt, +}: { + timeline: TimelineEvent[]; + status: 'running' | 'success' | 'error' | 'cancelled' | 'interrupted'; + error: string | null; + stoppedAt: number; +}): AgentExecutionFailureSummary | null { + let count = 0; + let latest: AgentExecutionFailure | null = null; + + const addFailure = (failure: AgentExecutionFailure) => { + count++; + if (latest === null || failure.occurredAt >= latest.occurredAt) latest = failure; + }; + + for (const event of timeline) { + if ( + event.type !== 'tool-call' || + event.endTime === 0 || + isDeclinedToolOutput(event.output) || + (event.success && !isSoftFailure(event)) + ) { + continue; + } + + addFailure({ + kind: event.kind, + name: event.nodeDisplayName ?? event.workflowName ?? event.name, + message: failureMessage(event.output), + occurredAt: event.endTime, + }); + } + + if (status === 'error' || status === 'interrupted') { + const message = error?.trim(); + addFailure({ + kind: 'execution', + name: null, + message: message ? message.slice(0, MAX_FAILURE_MESSAGE_LENGTH) : null, + occurredAt: stoppedAt, + }); + } + + return latest ? { count, latest } : null; +} diff --git a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts index 6a27f46a2a5..e187cd71981 100644 --- a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts @@ -210,4 +210,37 @@ describe('AgentExecutionRepository', () => { expect(result.has(thread.id)).toBe(false); }); }); + + describe('failure summaries', () => { + it('aggregates counts and the latest failure per thread', async () => { + const thread = await createThread(); + await createExecution({ + threadId: thread.id, + failureSummary: { + count: 1, + latest: { kind: 'tool', name: 'Lookup', message: 'failed', occurredAt: 10 }, + }, + }); + const latest = await createExecution({ + threadId: thread.id, + failureSummary: { + count: 2, + latest: { kind: 'execution', name: null, message: 'stopped', occurredAt: 20 }, + }, + }); + + const result = await repository.findFailureSummariesByThreadIds([thread.id]); + + expect(result.get(thread.id)).toEqual({ + count: 3, + latest: { + kind: 'execution', + name: null, + message: 'stopped', + occurredAt: 20, + executionId: latest.id, + }, + }); + }); + }); }); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index dfe55fb5d60..bb1aa1bdd82 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1470,9 +1470,12 @@ "agentSessions.showError.delete": "Problem deleting session", "agentSessions.showError.load": "Problem loading sessions", "agentSessions.status": "Status", + "agentSessions.status.cancelled": "Canceled", + "agentSessions.status.interrupted": "Interrupted", + "agentSessions.status.running": "Running", "agentSessions.duration": "Duration", "agentSessions.sessionId": "Session ID", - "agentSessions.success": "Success", + "agentSessions.success": "Succeeded", "agentSessions.detail.selectMessage": "Click an assistant message to view details", "agentSessions.detail.tokenUsage": "Token Usage", "agentSessions.detail.input": "Input", @@ -1524,6 +1527,11 @@ "agentSessions.timeline.memoryUpdated": "Memory updated", "agentSessions.timeline.openForm": "Open form", "agentSessions.timeline.workflowError": "Workflow call did not produce an execution", + "agentSessions.timeline.nodeError": "Tool experienced an error", + "agentSessions.timeline.executionFailed": "Execution failed", + "agentSessions.timeline.executionInterrupted": "Execution interrupted", + "agentSessions.timeline.executionFailedFallback": "The agent execution failed before completing.", + "agentSessions.timeline.executionInterruptedFallback": "The agent execution was interrupted before completing.", "agentSessions.timeline.toolError": "Tool call failed", "agentSessions.timeline.failed": "Failed", "agentSessions.timeline.filter": "Filter", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionTimelinePanel.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionTimelinePanel.spec.ts index 465cc959e3d..c0fad19a6b2 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionTimelinePanel.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionTimelinePanel.spec.ts @@ -84,6 +84,7 @@ const keyboardExecution = { }, ], error: null, + failureSummary: null, hitlStatus: null, source: null, } satisfies ThreadDetail['executions'][number]; @@ -195,7 +196,7 @@ describe('AgentSessionTimelinePanel', () => { options .filter((option) => option.presentation === 'badge') .map(({ key, count }) => [key, count]), - ).toEqual([['error', 1]]); + ).toEqual([['error', 2]]); }); it('omits status pills when the session has no matching statuses', async () => { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts index 8aaee3d6659..34d5ced9c46 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts @@ -34,17 +34,28 @@ let documentRemoveEventListenerSpy: ReturnType; vi.mock('@n8n/i18n', () => ({ useI18n: () => ({ - baseText: (key: string) => - ({ - 'agentSessions.viewTrace': 'View session trace', - 'agentSessions.origin.preview': 'Preview', - 'agentSessions.origin.instanceAi': 'AI Assistant', - 'agentSessions.origin.mcp': 'MCP', - 'agentSessions.origin.subAgent': 'Sub-agent', - 'agentSessions.origin.schedule': 'Schedule', - 'agentSessions.origin.workflow': 'Workflow', - 'agentSessions.empty': 'No agent sessions', - })[key] ?? key, + baseText: (key: string, options?: { interpolate?: Record }) => { + if (key === 'executionDetails.runningTimeFinished') { + return `in ${options?.interpolate?.time}`; + } + return ( + { + 'agentSessions.viewTrace': 'View session trace', + 'agentSessions.origin.preview': 'Preview', + 'agentSessions.origin.instanceAi': 'AI Assistant', + 'agentSessions.origin.mcp': 'MCP', + 'agentSessions.origin.subAgent': 'Sub-agent', + 'agentSessions.origin.schedule': 'Schedule', + 'agentSessions.origin.workflow': 'Workflow', + 'agentSessions.empty': 'No agent sessions', + 'agentSessions.success': 'Succeeded', + 'agentSessions.status.cancelled': 'Canceled', + 'agentSessions.status.interrupted': 'Interrupted', + 'agentSessions.status.running': 'Running', + 'agentSessions.timeline.error': 'Error', + }[key] ?? key + ); + }, }), })); @@ -66,7 +77,10 @@ vi.mock('@n8n/design-system', () => ({ template: '', }, N8nTableBase: { template: '
' }, - N8nTooltip: { template: '
' }, + N8nText: { + props: ['color'], + template: '', + }, })); vi.mock('../agentSessions.store', () => ({ @@ -145,6 +159,8 @@ function makeThread(overrides: Partial = {}): AgentExecuti createdAt: '2026-07-20T10:00:00.000Z', updatedAt: '2026-07-20T10:05:00.000Z', firstMessage: null, + failureSummary: null, + status: 'success', ...overrides, }; } @@ -205,7 +221,7 @@ describe('AgentSessionsListView', () => { expect(traceButton.element.tagName).toBe('BUTTON'); expect(traceButton.attributes('type')).toBe('button'); - expect(traceButton.text()).toBe('My session'); + expect(wrapper.get('[data-test-id="agent-session-title"]').text()).toBe('My session'); routerPush.mockClear(); await traceButton.trigger('click'); @@ -213,6 +229,25 @@ describe('AgentSessionsListView', () => { expect(routerPush).toHaveBeenCalledExactlyOnceWith(expectedRoute); }); + it.each([ + ['success', 'Succeeded', 'success', true], + ['error', 'Error', 'danger', true], + ['cancelled', 'Canceled', 'warning', true], + ['interrupted', 'Interrupted', 'warning', true], + ['running', 'Running', 'text-base', false], + ] as const)('renders the %s session state', async (status, label, color, showsDuration) => { + const wrapper = await mountView({ + threads: [makeThread({ status })], + }); + const indicator = wrapper.get('[data-testid="agent-session-status-indicator"]'); + + expect(indicator.text()).toBe(label); + expect(indicator.attributes('data-color')).toBe(color); + expect(wrapper.find('[data-testid="agent-session-status-duration"]').exists()).toBe( + showsDuration, + ); + }); + it('opens the parent trace in the current tab by default', async () => { const wrapper = await mountView({ threads: [makeThread({ parentAgentId: 'parent-agent-1', parentThreadId: 'parent-thread-1' })], diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/SessionDetailPanel.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionDetailPanel.spec.ts index 2e26044e99b..4a6bd991af5 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/SessionDetailPanel.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionDetailPanel.spec.ts @@ -284,6 +284,21 @@ describe('SessionDetailPanel — HITL sequence', () => { }); describe('SessionDetailPanel — other kinds', () => { + it('shows a fatal execution error in a danger callout', () => { + const w = mountIt({ + kind: 'execution-error', + executionId: 'e1', + executionStatus: 'error', + timestamp: 100, + content: 'Model request failed', + }); + + expect(w.get('[data-testid="execution-error-callout"]').text()).toContain( + 'Model request failed', + ); + expect(w.get('[data-test-id="detail-execution-error-badge"]').text()).toBe('Error'); + }); + it('renders Input/Output JSON sections for generic tool calls', () => { const w = mountIt({ kind: 'tool', diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineChart.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineChart.spec.ts index 8779f27876d..bbb5f0dc477 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineChart.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineChart.spec.ts @@ -96,6 +96,23 @@ describe('SessionTimelineChart', () => { expect(blocks[1].attributes('style')).not.toMatch(/opacity:\s*0\.15/); }); + it('renders a synthetic execution error as a danger block', () => { + const w = mountChart({ + items: [ + item({ + kind: 'execution-error', + executionStatus: 'error', + content: 'Model request failed', + timestamp: 1000, + }), + ], + }); + const block = w.get('[data-test-id="timeline-block"]'); + + expect(block.attributes('data-error')).toBe('true'); + expect(block.attributes('style')).toContain('var(--color--red-400)'); + }); + it('renders idle blobs interleaved with events in chronological order', () => { const w = mountChart({ idleRanges: [{ start: 1500, end: 2000 }] }); expect(w.findAll('[data-test-id="timeline-idle"]')).toHaveLength(1); @@ -127,7 +144,6 @@ describe('SessionTimelineChart', () => { }); const block = w.get('[data-test-id="timeline-block"]'); expect(block.attributes('data-error')).toBe('true'); - expect(block.classes()).toContain('error'); }); it('marks a workflow soft-failure block as failed', () => { @@ -142,7 +158,6 @@ describe('SessionTimelineChart', () => { }); const block = w.get('[data-test-id="timeline-block"]'); expect(block.attributes('data-error')).toBe('true'); - expect(block.classes()).toContain('error'); }); it('does not mark a successful tool block as failed', () => { @@ -157,7 +172,6 @@ describe('SessionTimelineChart', () => { }); const block = w.get('[data-test-id="timeline-block"]'); expect(block.attributes('data-error')).toBeUndefined(); - expect(block.classes()).not.toContain('error'); }); it('renders the localized "Idle" pill text inside each idle segment', () => { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineTable.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineTable.spec.ts index 917ad443d72..c776c0b2584 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineTable.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineTable.spec.ts @@ -196,6 +196,25 @@ describe('SessionTimelineTable', () => { expect(w.get('[data-test-id="timeline-tool-error-badge"]').text()).toBe('Error'); }); + it('renders a fatal execution as an error row', () => { + const w = mountTable({ + items: [ + { + kind: 'execution-error', + executionId: 'e1', + executionStatus: 'error', + timestamp: 1000, + content: 'Model request failed', + }, + ], + selectedIndex: null, + visibleKinds: new Set(), + }); + + expect(w.get('[data-test-id="timeline-row"]').text()).toContain('Model request failed'); + expect(w.get('[data-test-id="timeline-execution-error-badge"]').text()).toBe('Error'); + }); + it.each([ ['approved', 'Approved'], ['declined', 'Declined'], diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/session-timeline.utils.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/session-timeline.utils.spec.ts index b431ae77f31..e45886a7f3d 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/session-timeline.utils.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/session-timeline.utils.spec.ts @@ -88,6 +88,7 @@ describe('timeline status filters', () => { const approved = item({ kind: 'hitl-response', hitlResponseStatus: 'approved' }); const declined = item({ kind: 'hitl-response', hitlResponseStatus: 'declined' }); const errored = item({ kind: 'tool', toolOutcome: 'error' }); + const executionError = item({ kind: 'execution-error', executionStatus: 'error' }); const handledWorkflowError = item({ kind: 'workflow', toolOutcome: 'success', @@ -99,6 +100,7 @@ describe('timeline status filters', () => { expect(itemStatusFilterKey(declined)).toBe('declined'); expect(itemStatusFilterKey(errored)).toBe('error'); expect(itemStatusFilterKey(handledWorkflowError)).toBe('error'); + expect(itemStatusFilterKey(executionError)).toBe('error'); expect( itemStatusFilterKey(item({ kind: 'hitl-response', hitlResponseStatus: 'responded' })), ).toBeUndefined(); @@ -112,6 +114,7 @@ describe('timeline status filters', () => { expect(matchesTimelineFilters(errored, new Set(['error']))).toBe(true); expect(matchesTimelineFilters(errored, new Set(['tool']))).toBe(true); expect(matchesTimelineFilters(handledWorkflowError, new Set(['error']))).toBe(true); + expect(matchesTimelineFilters(executionError, new Set(['error']))).toBe(true); }); }); @@ -226,6 +229,7 @@ function exec(overrides: Partial = {}): AgentExecution { cost: null, timeline: null, error: null, + failureSummary: null, hitlStatus: null, source: null, ...overrides, @@ -294,6 +298,36 @@ describe('flattenExecutionsToTimelineItems', () => { expect(items).toHaveLength(0); }); + it.each([ + ['error', 'Model failed'], + ['interrupted', 'Agent execution was interrupted'], + ] as const)('adds a selectable synthetic item for an %s execution', (status, error) => { + const items = flattenExecutionsToTimelineItems([ + exec({ + status, + error, + stoppedAt: '2026-04-24T10:00:05Z', + }), + ]); + + expect(items).toEqual([ + { + kind: 'execution-error', + executionId: 'e-1', + executionStatus: status, + content: error, + timestamp: Date.parse('2026-04-24T10:00:05Z'), + }, + ]); + }); + + it.each(['success', 'cancelled'] as const)( + 'does not add a synthetic item for a %s execution', + (status) => { + expect(flattenExecutionsToTimelineItems([exec({ status })])).toEqual([]); + }, + ); + it('maps a generic HITL flow to tool call, request, and user response items', () => { const items = flattenExecutionsToTimelineItems([ withTimeline( diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentSessionTimelinePanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentSessionTimelinePanel.vue index d5e0dee085f..e2022d8269c 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentSessionTimelinePanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentSessionTimelinePanel.vue @@ -101,6 +101,10 @@ function labelForKey(key: string): string { return i18n.baseText('agentSessions.timeline.workflow'); case 'node': return i18n.baseText('agentSessions.timeline.node'); + case 'execution-error': + return i18n.baseText('agentSessions.timeline.executionFailed'); + case 'execution-interrupted': + return i18n.baseText('agentSessions.timeline.executionInterrupted'); case 'suspension': return i18n.baseText('agentSessions.timeline.hitlRequest'); case 'hitl-response': @@ -135,7 +139,9 @@ const filterOptions = computed(() => { const kindCounts = new Map(); const statusCounts = new Map(); for (const item of items.value) { - kindCounts.set(item.kind, (kindCounts.get(item.kind) ?? 0) + 1); + if (item.kind !== 'execution-error') { + kindCounts.set(item.kind, (kindCounts.get(item.kind) ?? 0) + 1); + } const statusKey = itemStatusFilterKey(item); if (statusKey) { statusCounts.set(statusKey, (statusCounts.get(statusKey) ?? 0) + 1); diff --git a/packages/frontend/editor-ui/src/features/agents/components/SessionDetailPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/SessionDetailPanel.vue index 7cdba9b165f..7033cf8757a 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/SessionDetailPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/SessionDetailPanel.vue @@ -24,6 +24,8 @@ import WorkflowExecutionLogViewer from './WorkflowExecutionLogViewer.vue'; import ToolIoView from './ToolIoView.vue'; import type { TimelineItem } from '../session-timeline.types'; import { + executionErrorLabel, + executionErrorMessage, hitlTimelineName, isErroredToolCallTimelineItem, isSubAgentTimelineItem, @@ -176,6 +178,7 @@ const headerTitle = computed((): string => { if (item.kind === 'node') return item.nodeDisplayName ?? formatToolNameForDisplay(item.toolName); if (item.kind === 'user') return i18n.baseText('agentSessions.timeline.user'); if (item.kind === 'agent') return i18n.baseText('agentSessions.timeline.agent'); + if (item.kind === 'execution-error') return executionErrorLabel(item, i18n); if (item.kind === 'suspension') { return item.hitlRequestType === 'approval' ? hitlTimelineName(item, i18n) @@ -195,6 +198,7 @@ const headerIcon = computed((): IconName => { if (item.kind === 'node') return 'box'; if (item.kind === 'user') return 'user'; if (item.kind === 'agent') return 'bot'; + if (item.kind === 'execution-error') return 'circle-x'; if (item.kind === 'hitl-response') return 'message-square'; return 'clock'; }); @@ -243,7 +247,9 @@ const workflowFormOutput = computed((): { formUrl: string; message: string } | n :data-test-id=" status.kind === 'hitl-response' ? 'detail-hitl-response-badge' - : 'detail-tool-error-badge' + : item.kind === 'execution-error' + ? 'detail-execution-error-badge' + : 'detail-tool-error-badge' " > {{ i18n.baseText(status.labelKey) }} @@ -281,7 +287,13 @@ const workflowFormOutput = computed((): { formUrl: string; message: string } | n
-