fix(core): Report and reconcile agent channel startup failures (#36578)

Co-authored-by: Danny Martini <danny@n8n.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Benjamin Schroth
2026-08-25 14:35:15 +00:00
committed by GitHub
parent cdfadef61e
commit 6b0de0d60f
43 changed files with 4193 additions and 257 deletions
+15
View File
@@ -8,6 +8,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| Name | Columns | Comment | Type |
| ---- | ------- | ------- | ---- |
| [public.agent_channel_status](public.agent_channel_status.md) | 11 | | BASE TABLE |
| [public.agent_chat_attachments](public.agent_chat_attachments.md) | 12 | | BASE TABLE |
| [public.agent_chat_subscriptions](public.agent_chat_subscriptions.md) | 6 | | BASE TABLE |
| [public.agent_checkpoints](public.agent_checkpoints.md) | 6 | | BASE TABLE |
@@ -160,6 +161,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
```mermaid
erDiagram
"public.agent_channel_status" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_chat_attachments" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.agent_chat_attachments" }o--o| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_chat_subscriptions" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
@@ -347,6 +349,19 @@ erDiagram
"public.workflows_tags" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflows_tags" }o--|| "public.tag_entity" : "FOREIGN KEY (#quot;tagId#quot;) REFERENCES tag_entity(id) ON DELETE CASCADE"
"public.agent_channel_status" {
varchar_36_ agentId FK
integer attempts
timestamp_3__with_time_zone backoffUntil
timestamp_3__with_time_zone createdAt
varchar_36_ credentialId
text errorMessage
timestamp_3__with_time_zone expiresAt
varchar_128_ hostId
varchar_64_ integrationType
varchar_16_ status
timestamp_3__with_time_zone updatedAt
}
"public.agent_chat_attachments" {
varchar_36_ agentId FK
text binaryDataId
@@ -0,0 +1,83 @@
# public.agent_channel_status
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| agentId | varchar(36) | | false | | [public.agents](public.agents.md) | Agent that owns this channel |
| attempts | integer | 0 | false | | | Consecutive failed startup attempts by this process, reset on success |
| backoffUntil | timestamp(3) with time zone | | true | | | Earliest this process should retry; null when there is nothing to retry |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| credentialId | varchar(36) | | false | | | Credential connection that backs this channel; no FK so a failure is still recordable after the credential is deleted |
| errorMessage | text | | true | | | Why this process could not start the channel; null once it succeeds |
| expiresAt | timestamp(3) with time zone | | true | | | When this row stops counting unless its owner refreshes it; null never expires |
| hostId | varchar(128) | | false | | | Process that observed this; the only writer of this row |
| integrationType | varchar(64) | | false | | | Chat integration platform for this channel |
| status | varchar(16) | | false | | | What this process last observed: connected or error |
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| CHK_agent_channel_status_status | CHECK | CHECK (((status)::text = ANY ((ARRAY['connected'::character varying, 'error'::character varying])::text[]))) |
| FK_7a723e6aad04d88057dea6a21f4 | FOREIGN KEY | FOREIGN KEY ("agentId") REFERENCES agents(id) ON DELETE CASCADE |
| PK_4e1ca943734d575679a56f4da90 | PRIMARY KEY | PRIMARY KEY ("agentId", "integrationType", "credentialId", "hostId") |
| agent_channel_status_agentId_not_null | n | NOT NULL "agentId" |
| agent_channel_status_attempts_not_null | n | NOT NULL attempts |
| agent_channel_status_createdAt_not_null | n | NOT NULL "createdAt" |
| agent_channel_status_credentialId_not_null | n | NOT NULL "credentialId" |
| agent_channel_status_hostId_not_null | n | NOT NULL "hostId" |
| agent_channel_status_integrationType_not_null | n | NOT NULL "integrationType" |
| agent_channel_status_status_not_null | n | NOT NULL status |
| agent_channel_status_updatedAt_not_null | n | NOT NULL "updatedAt" |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_a7c67724df9ef3d12f6da773c1 | CREATE INDEX "IDX_a7c67724df9ef3d12f6da773c1" ON public.agent_channel_status USING btree ("expiresAt") |
| IDX_c75ec632fe0b5b1905edb9cd7f | CREATE INDEX "IDX_c75ec632fe0b5b1905edb9cd7f" ON public.agent_channel_status USING btree ("hostId") |
| PK_4e1ca943734d575679a56f4da90 | CREATE UNIQUE INDEX "PK_4e1ca943734d575679a56f4da90" ON public.agent_channel_status USING btree ("agentId", "integrationType", "credentialId", "hostId") |
## Relations
```mermaid
erDiagram
"public.agent_channel_status" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_channel_status" {
varchar_36_ agentId FK
integer attempts
timestamp_3__with_time_zone backoffUntil
timestamp_3__with_time_zone createdAt
varchar_36_ credentialId
text errorMessage
timestamp_3__with_time_zone expiresAt
varchar_128_ hostId
varchar_64_ integrationType
varchar_16_ status
timestamp_3__with_time_zone updatedAt
}
"public.agents" {
varchar_36_ activeVersionId FK
boolean availableInMCP
timestamp_3__with_time_zone createdAt
varchar_36_ id
json integrations
varchar_128_ name
varchar_255_ projectId FK
integer revision
json schema
timestamp_3__with_time_zone setupCompletedAt
json skills
json tools
timestamp_3__with_time_zone updatedAt
varchar_36_ versionId
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)
+15 -1
View File
@@ -7,7 +7,7 @@
| activeVersionId | varchar(36) | | true | | [public.agent_history](public.agent_history.md) | |
| availableInMCP | boolean | false | false | | | Whether MCP clients granted agent scopes may operate on this agent |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| id | varchar(36) | | false | [public.agent_chat_attachments](public.agent_chat_attachments.md) [public.agent_chat_subscriptions](public.agent_chat_subscriptions.md) [public.agent_checkpoints](public.agent_checkpoints.md) [public.agent_credential_dependency](public.agent_credential_dependency.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_files](public.agent_files.md) [public.agent_history](public.agent_history.md) [public.agent_task_definition](public.agent_task_definition.md) [public.agent_task_run_lock](public.agent_task_run_lock.md) [public.agents_memory_entries](public.agents_memory_entries.md) [public.agents_memory_entry_cursors](public.agents_memory_entry_cursors.md) [public.agents_memory_entry_locks](public.agents_memory_entry_locks.md) [public.agents_memory_entry_sources](public.agents_memory_entry_sources.md) [public.agents_observation_cursors](public.agents_observation_cursors.md) [public.agents_observation_locks](public.agents_observation_locks.md) [public.agents_observations](public.agents_observations.md) | | |
| id | varchar(36) | | false | [public.agent_channel_status](public.agent_channel_status.md) [public.agent_chat_attachments](public.agent_chat_attachments.md) [public.agent_chat_subscriptions](public.agent_chat_subscriptions.md) [public.agent_checkpoints](public.agent_checkpoints.md) [public.agent_credential_dependency](public.agent_credential_dependency.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_files](public.agent_files.md) [public.agent_history](public.agent_history.md) [public.agent_task_definition](public.agent_task_definition.md) [public.agent_task_run_lock](public.agent_task_run_lock.md) [public.agents_memory_entries](public.agents_memory_entries.md) [public.agents_memory_entry_cursors](public.agents_memory_entry_cursors.md) [public.agents_memory_entry_locks](public.agents_memory_entry_locks.md) [public.agents_memory_entry_sources](public.agents_memory_entry_sources.md) [public.agents_observation_cursors](public.agents_observation_cursors.md) [public.agents_observation_locks](public.agents_observation_locks.md) [public.agents_observations](public.agents_observations.md) | | |
| integrations | json | '[]'::json | false | | | |
| name | varchar(128) | | false | | | |
| projectId | varchar(255) | | false | | [public.project](public.project.md) | |
@@ -51,6 +51,7 @@
erDiagram
"public.agents" }o--o| "public.agent_history" : "FOREIGN KEY (#quot;activeVersionId#quot;) REFERENCES agent_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.agent_channel_status" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_chat_attachments" }o--o| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_chat_subscriptions" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_checkpoints" }o--o| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
@@ -97,6 +98,19 @@ erDiagram
timestamp_3__with_time_zone updatedAt
varchar_36_ versionId
}
"public.agent_channel_status" {
varchar_36_ agentId FK
integer attempts
timestamp_3__with_time_zone backoffUntil
timestamp_3__with_time_zone createdAt
varchar_36_ credentialId
text errorMessage
timestamp_3__with_time_zone expiresAt
varchar_128_ hostId
varchar_64_ integrationType
varchar_16_ status
timestamp_3__with_time_zone updatedAt
}
"public.agent_chat_attachments" {
varchar_36_ agentId FK
text binaryDataId
+15
View File
@@ -8,6 +8,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| Name | Columns | Comment | Type |
| ---- | ------- | ------- | ---- |
| [agent_channel_status](agent_channel_status.md) | 11 | | table |
| [agent_chat_attachments](agent_chat_attachments.md) | 12 | | table |
| [agent_chat_subscriptions](agent_chat_subscriptions.md) | 6 | | table |
| [agent_checkpoints](agent_checkpoints.md) | 6 | | table |
@@ -143,6 +144,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
```mermaid
erDiagram
"agent_channel_status" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_chat_attachments" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_chat_attachments" }o--o| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_chat_subscriptions" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -334,6 +336,19 @@ erDiagram
"workflows_tags" |o--|| "tag_entity" : "FOREIGN KEY (tagId) REFERENCES tag_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"workflows_tags" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_channel_status" {
varchar_36_ agentId PK
INTEGER attempts
datetime_3_ backoffUntil
datetime_3_ createdAt
varchar_36_ credentialId PK
TEXT errorMessage
datetime_3_ expiresAt
varchar_128_ hostId PK
varchar_64_ integrationType PK
varchar_16_ status
datetime_3_ updatedAt
}
"agent_chat_attachments" {
varchar_36_ agentId FK
TEXT binaryDataId
+90
View File
@@ -0,0 +1,90 @@
# agent_channel_status
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "agent_channel_status" ("agentId" varchar(36) NOT NULL, "integrationType" varchar(64) NOT NULL, "credentialId" varchar(36) NOT NULL, "hostId" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "errorMessage" text, "attempts" integer NOT NULL DEFAULT (0), "backoffUntil" datetime(3), "expiresAt" datetime(3), "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')), CONSTRAINT "CHK_agent_channel_status_status" CHECK ("status" IN ('connected', 'error')), CONSTRAINT "FK_7a723e6aad04d88057dea6a21f4" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE, PRIMARY KEY ("agentId", "integrationType", "credentialId", "hostId"))
```
</details>
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| agentId | varchar(36) | | false | | [agents](agents.md) | |
| attempts | INTEGER | 0 | false | | | |
| backoffUntil | datetime(3) | | true | | | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| credentialId | varchar(36) | | false | | | |
| errorMessage | TEXT | | true | | | |
| expiresAt | datetime(3) | | true | | | |
| hostId | varchar(128) | | false | | | |
| integrationType | varchar(64) | | false | | | |
| status | varchar(16) | | false | | | |
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - | CHECK | CHECK ("status" IN ('connected', 'error')) |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| agentId | PRIMARY KEY | PRIMARY KEY (agentId) |
| credentialId | PRIMARY KEY | PRIMARY KEY (credentialId) |
| hostId | PRIMARY KEY | PRIMARY KEY (hostId) |
| integrationType | PRIMARY KEY | PRIMARY KEY (integrationType) |
| sqlite_autoindex_agent_channel_status_1 | PRIMARY KEY | PRIMARY KEY (agentId, integrationType, credentialId, hostId) |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_a7c67724df9ef3d12f6da773c1 | CREATE INDEX "IDX_a7c67724df9ef3d12f6da773c1" ON "agent_channel_status" ("expiresAt") |
| IDX_c75ec632fe0b5b1905edb9cd7f | CREATE INDEX "IDX_c75ec632fe0b5b1905edb9cd7f" ON "agent_channel_status" ("hostId") |
| sqlite_autoindex_agent_channel_status_1 | PRIMARY KEY (agentId, integrationType, credentialId, hostId) |
## Relations
```mermaid
erDiagram
"agent_channel_status" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_channel_status" {
varchar_36_ agentId PK
INTEGER attempts
datetime_3_ backoffUntil
datetime_3_ createdAt
varchar_36_ credentialId PK
TEXT errorMessage
datetime_3_ expiresAt
varchar_128_ hostId PK
varchar_64_ integrationType PK
varchar_16_ status
datetime_3_ updatedAt
}
"agents" {
varchar_36_ activeVersionId FK
boolean availableInMCP
datetime_3_ createdAt
varchar_36_ id PK
TEXT integrations
varchar_128_ name
varchar_255_ projectId FK
INTEGER revision
TEXT schema
datetime_3_ setupCompletedAt
TEXT skills
TEXT tools
datetime_3_ updatedAt
varchar_36_ versionId
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)
+15 -1
View File
@@ -18,7 +18,7 @@ CREATE TABLE "agents" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128
| activeVersionId | varchar(36) | | true | | [agent_history](agent_history.md) | |
| availableInMCP | boolean | false | false | | | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| id | varchar(36) | | false | [agent_chat_attachments](agent_chat_attachments.md) [agent_chat_subscriptions](agent_chat_subscriptions.md) [agent_checkpoints](agent_checkpoints.md) [agent_credential_dependency](agent_credential_dependency.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_execution_threads](agent_execution_threads.md) [agent_files](agent_files.md) [agent_history](agent_history.md) [agent_task_definition](agent_task_definition.md) [agent_task_run_lock](agent_task_run_lock.md) [agents_memory_entries](agents_memory_entries.md) [agents_memory_entry_cursors](agents_memory_entry_cursors.md) [agents_memory_entry_locks](agents_memory_entry_locks.md) [agents_memory_entry_sources](agents_memory_entry_sources.md) [agents_observation_cursors](agents_observation_cursors.md) [agents_observation_locks](agents_observation_locks.md) [agents_observations](agents_observations.md) | | |
| id | varchar(36) | | false | [agent_channel_status](agent_channel_status.md) [agent_chat_attachments](agent_chat_attachments.md) [agent_chat_subscriptions](agent_chat_subscriptions.md) [agent_checkpoints](agent_checkpoints.md) [agent_credential_dependency](agent_credential_dependency.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_execution_threads](agent_execution_threads.md) [agent_files](agent_files.md) [agent_history](agent_history.md) [agent_task_definition](agent_task_definition.md) [agent_task_run_lock](agent_task_run_lock.md) [agents_memory_entries](agents_memory_entries.md) [agents_memory_entry_cursors](agents_memory_entry_cursors.md) [agents_memory_entry_locks](agents_memory_entry_locks.md) [agents_memory_entry_sources](agents_memory_entry_sources.md) [agents_observation_cursors](agents_observation_cursors.md) [agents_observation_locks](agents_observation_locks.md) [agents_observations](agents_observations.md) | | |
| integrations | TEXT | '[]' | false | | | |
| name | varchar(128) | | false | | | |
| projectId | varchar(255) | | false | | [project](project.md) | |
@@ -53,6 +53,7 @@ CREATE TABLE "agents" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128
erDiagram
"agents" }o--o| "agent_history" : "FOREIGN KEY (activeVersionId) REFERENCES agent_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agent_channel_status" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_chat_attachments" }o--o| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_chat_subscriptions" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_checkpoints" }o--o| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -99,6 +100,19 @@ erDiagram
datetime_3_ updatedAt
varchar_36_ versionId PK
}
"agent_channel_status" {
varchar_36_ agentId PK
INTEGER attempts
datetime_3_ backoffUntil
datetime_3_ createdAt
varchar_36_ credentialId PK
TEXT errorMessage
datetime_3_ expiresAt
varchar_128_ hostId PK
varchar_64_ integrationType PK
varchar_16_ status
datetime_3_ updatedAt
}
"agent_chat_attachments" {
varchar_36_ agentId FK
TEXT binaryDataId
+29 -1
View File
@@ -119,14 +119,33 @@ export interface ChatIntegrationDescriptor {
useNodeToolWhen?: string[];
}
/**
* What one configured channel is doing.
*
* - `configured` — set up, but its agent is not published, so it must not run.
* - `starting` — should be running; no startup attempt has reported back yet.
* - `connected` — running.
* - `error` — the last startup attempt failed; `errorMessage` says why, and
* it is being retried.
*/
export type AgentChannelRuntimeStatus = 'configured' | 'starting' | 'connected' | 'error';
export interface AgentIntegrationStatusEntry {
type: string;
credentialId?: string;
settings?: AgentIntegrationSettings;
/** Authoritative per-channel state; prefer this over the response rollup. */
status: AgentChannelRuntimeStatus;
/** Present only when `status` is `error`. */
errorMessage?: string;
}
export interface AgentIntegrationStatusResponse {
status: 'configured' | 'connected' | 'disconnected';
/**
* Rollup across `integrations`, for callers that only need one word:
* `disconnected` with none configured, `partial` when the channels disagree.
*/
status: 'configured' | 'connected' | 'disconnected' | 'partial' | 'error';
integrations: AgentIntegrationStatusEntry[];
}
@@ -145,6 +164,15 @@ export interface AgentIntegrationDisconnectWarning {
details?: Record<string, string>;
}
/**
* The state a connect left the one channel it touched in. Narrower than the
* status rollup: a successful connect either started the channel or persisted it
* for a still-unpublished agent, and any other outcome is an error response.
*/
export interface AgentIntegrationConnectResponse {
status: Extract<AgentChannelRuntimeStatus, 'configured' | 'connected'>;
}
export interface AgentSkillReference {
path: string;
content: string;
@@ -93,4 +93,13 @@ export class AgentsConfig {
/** When true, Daytona deletes the knowledge sandbox when it stops. */
@Env('N8N_AGENTS_AI_SANDBOX_EPHEMERAL')
sandboxEphemeral: boolean = false;
/**
* How often (seconds) each main checks that the channels of its published
* agents are actually running, and retries the ones that are not. Set to 0 to
* stop checking, which leaves a channel that failed to start down until the
* agent is republished or the instance restarts.
*/
@Env('N8N_AGENTS_CHANNEL_RECONCILE_INTERVAL')
channelReconcileIntervalSeconds: number = 60;
}
+1
View File
@@ -693,6 +693,7 @@ describe('GlobalConfig', () => {
sandboxSnapshot: 'daytonaio/sandbox:0.8.0',
sandboxTimeout: 300000,
sandboxEphemeral: false,
channelReconcileIntervalSeconds: 60,
},
} satisfies GlobalConfigShape;
@@ -0,0 +1,68 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const CHANNEL_STATUSES = ['connected', 'error'];
/**
* Per-process rows: `hostId` is part of the key so each row has one writer, and
* mains running the same channel never overwrite one another.
*
* `integrationType` deliberately has no enum check: it mirrors the platform
* registry, and constraining it here would mean a migration every time a
* platform is added — for a table that only reports what happened.
*
* `credentialId` deliberately has no foreign key. A deleted credential is one of
* the likeliest reasons a channel is down, and deleting one neither rewrites the
* agent's channels nor stops them being started, so the row saying "credential
* not found" has to be writable even after its credential is gone.
*/
export class CreateAgentChannelStatusTable1787213245846 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable('agent_channel_status')
.withColumns(
column('agentId').varchar(36).primary.comment('Agent that owns this channel'),
column('integrationType')
.varchar(64)
.primary.comment('Chat integration platform for this channel'),
column('credentialId')
.varchar(36)
.primary.comment(
'Credential connection that backs this channel; no FK so a failure is still recordable after the credential is deleted',
),
column('hostId')
.varchar(128)
.primary.comment('Process that observed this; the only writer of this row'),
column('status')
.varchar(16)
.notNull.withEnumCheck(CHANNEL_STATUSES)
.comment('What this process last observed: connected or error'),
column('errorMessage').text.comment(
'Why this process could not start the channel; null once it succeeds',
),
column('attempts')
.int.notNull.default(0)
.comment('Consecutive failed startup attempts by this process, reset on success'),
column('backoffUntil')
.timestampTimezone()
.comment('Earliest this process should retry; null when there is nothing to retry'),
column('expiresAt')
.timestampTimezone()
.comment(
'When this row stops counting unless its owner refreshes it; null never expires',
),
)
.withForeignKey('agentId', {
tableName: 'agents',
columnName: 'id',
onDelete: 'CASCADE',
})
// Every pass reads this instance's own rows, and `hostId` is last in the
// primary key, so there is no prefix to use.
.withIndexOn(['hostId'])
// The leader sweeps rows left behind by processes that crashed.
.withIndexOn(['expiresAt']).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('agent_channel_status');
}
}
@@ -167,6 +167,20 @@ describe('scrubSecretsInText', () => {
expect(scrubSecretsInText(input)).toBe(input);
});
it('redacts a Discord bot token', () => {
const token = join('MTI', '3NDU2Nzg5MDEyMzQ1Njc4.Gh1jKl.abcdefghijklmnopqrstuvwxyz1');
expect(scrubSecretsInText(`login failed for ${token}`)).toBe('login failed for [REDACTED]');
});
it('redacts Linear API keys and OAuth tokens', () => {
expect(scrubSecretsInText(`key ${join('lin_api_', 'abcdefghij0123456789')}`)).toBe(
'key [REDACTED]',
);
expect(scrubSecretsInText(`using ${join('lin_oauth_', 'abcdefghij0123456789')}`)).toBe(
'using [REDACTED]',
);
});
it('redacts a Telegram bot token, including inside a /bot… URL', () => {
const url = join(
'https://api.telegram.org/bot',
+5
View File
@@ -39,6 +39,11 @@ export const SECRET_VALUE_PATTERNS: readonly RegExp[] = [
/\bAKIA[0-9A-Z]{16}\b/g,
// Telegram bot token (`<bot id>:<35-char secret>`, also inside `/bot…/` URLs)
/\b(?:bot)?\d{8,10}:[A-Za-z0-9_-]{35}\b/g,
// Discord bot token: three base64url segments, the first being the encoded
// application id (so it starts with the encoding of a snowflake's leading digit).
/\b[MNO][A-Za-z0-9_-]{22,}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/g,
// Linear API keys (`lin_api_…`) and OAuth tokens (`lin_oauth_…`)
/\blin_(?:api|oauth)_[A-Za-z0-9]{20,}/g,
// Credentials embedded in a URL: `scheme://user:password@` — redact the userinfo.
/(?<=:\/\/)[^\s:/@]+:[^\s:/@]+(?=@)/g,
// JSON-shaped `"key": "value"` — matches the quoted field as a whole.
@@ -5,9 +5,12 @@ import { mock } from 'vitest-mock-extended';
import type { AgentIntegrationManagementService } from '../agent-integration-management.service';
import { AgentIntegrationsController } from '../agent-integrations.controller';
import type { AgentChannelStatus } from '../entities/agent-channel-status.entity';
import type { Agent } from '../entities/agent.entity';
import type { ChatIntegrationRegistry } from '../integrations/agent-chat-integration';
import type { AgentChannelStatusReporter } from '../integrations/agent-channel-status-reporter';
import type { ChatIntegrationService } from '../integrations/chat-integration.service';
import type { AgentChannelStatusRepository } from '../repositories/agent-channel-status.repository';
import type { AgentRepository } from '../repositories/agent.repository';
import {
expectProjectScopedAgentRoutes,
@@ -21,22 +24,33 @@ function makeController({
chatIntegrationService = mock<ChatIntegrationService>(),
agentRepository = mock<AgentRepository>(),
chatIntegrationRegistry = mock<ChatIntegrationRegistry>(),
channelStatusRepository = mock<AgentChannelStatusRepository>(),
statusReporter = mock<AgentChannelStatusReporter>(),
}: {
managementService?: Mocked<AgentIntegrationManagementService>;
chatIntegrationService?: Mocked<ChatIntegrationService>;
agentRepository?: Mocked<AgentRepository>;
chatIntegrationRegistry?: Mocked<ChatIntegrationRegistry>;
channelStatusRepository?: Mocked<AgentChannelStatusRepository>;
statusReporter?: Mocked<AgentChannelStatusReporter>;
} = {}) {
channelStatusRepository.findByAgentId.mockResolvedValue([]);
statusReporter.isLive.mockReturnValue(true);
return {
controller: new AgentIntegrationsController(
managementService,
chatIntegrationService,
agentRepository,
chatIntegrationRegistry,
channelStatusRepository,
statusReporter,
),
managementService,
chatIntegrationService,
agentRepository,
channelStatusRepository,
statusReporter,
};
}
@@ -324,3 +338,110 @@ describe('AgentIntegrationsController integration management', () => {
});
});
});
describe('AgentIntegrationsController channel status', () => {
const slack = {
type: 'slack',
credentialId: 'credential-slack',
} satisfies AgentIntegrationConfig;
const telegram = {
type: 'telegram',
credentialId: 'credential-telegram',
} satisfies AgentIntegrationConfig;
const publishedAgent = {
id: 'agent-1',
projectId: 'project-1',
activeVersionId: 'version-1',
integrations: [slack, telegram],
} as unknown as Agent;
function liveRow(integration: AgentIntegrationConfig): AgentChannelStatus {
return {
agentId: publishedAgent.id,
integrationType: integration.type,
credentialId: integration.credentialId,
hostId: 'main-1',
status: 'connected',
errorMessage: null,
attempts: 0,
backoffUntil: null,
expiresAt: new Date(Date.now() + 60_000),
} as AgentChannelStatus;
}
async function statusOf(agent: Agent, rows: AgentChannelStatus[]) {
const { controller, agentRepository, channelStatusRepository, statusReporter } =
makeController();
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
channelStatusRepository.findByAgentId.mockResolvedValue(rows);
statusReporter.isLive.mockImplementation(
(row) => row.expiresAt === null || row.expiresAt.getTime() > Date.now(),
);
const response = await controller.integrationStatus(
{ params: { projectId: agent.projectId } } as never,
undefined as never,
agent.id,
);
return { response, channelStatusRepository, statusReporter };
}
it('reports a channel with a live row as connected and one without as starting', async () => {
const { response, channelStatusRepository } = await statusOf(publishedAgent, [liveRow(slack)]);
expect(channelStatusRepository.findByAgentId).toHaveBeenCalledWith(publishedAgent.id);
expect(response.integrations).toEqual([
{ type: slack.type, credentialId: slack.credentialId, status: 'connected' },
{ type: telegram.type, credentialId: telegram.credentialId, status: 'starting' },
]);
});
it('reports the reason a channel could not start', async () => {
const failed = {
...liveRow(telegram),
status: 'error',
errorMessage: 'Credential not found',
} as AgentChannelStatus;
const { response } = await statusOf(publishedAgent, [liveRow(slack), failed]);
expect(response.integrations).toEqual([
{ type: slack.type, credentialId: slack.credentialId, status: 'connected' },
{
type: telegram.type,
credentialId: telegram.credentialId,
status: 'error',
errorMessage: 'Credential not found',
},
]);
});
it('ignores a row whose lease has run out, so a dead instance stops reporting', async () => {
const expired = {
...liveRow(slack),
expiresAt: new Date(Date.now() - 60_000),
} as AgentChannelStatus;
const { response } = await statusOf(publishedAgent, [expired]);
expect(response.integrations[0]).toEqual({
type: slack.type,
credentialId: slack.credentialId,
status: 'starting',
});
});
it('throws when the agent is not in the project', async () => {
const { controller, agentRepository } = makeController();
agentRepository.findByIdAndProjectId.mockResolvedValue(null);
await expect(
controller.integrationStatus(
{ params: { projectId: 'project-1' } } as never,
undefined as never,
'agent-1',
),
).rejects.toThrow('Agent "agent-1" not found');
});
});
@@ -7,6 +7,7 @@ import { QueryFailedError } from '@n8n/typeorm';
import { mock } from 'vitest-mock-extended';
import type { CredentialsService } from '@/credentials/credentials.service';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import type { EventService } from '@/events/event.service';
import type { Telemetry } from '@/telemetry';
@@ -224,6 +225,86 @@ describe('AgentPublishService', () => {
expect(agent.activeVersionId).toBeNull();
});
describe('channel startup preflight', () => {
const telegram = { type: 'telegram', credentialId: 'cred-1' } as const;
it('rejects the publish when a channel cannot start, leaving nothing written', async () => {
const {
service,
agentRepository,
agentHistoryRepository,
chatIntegrationService,
runtimeCacheService,
trx,
} = makeService();
const agent = makeAgent({ integrations: [telegram] });
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
chatIntegrationService.assertStartupPreconditions.mockRejectedValue(
new ConflictError('This Telegram credential is already connected to agent "Other"'),
);
await expect(service.publishAgent(agentId, projectId, user, byUser)).rejects.toThrow(
ConflictError,
);
expect(agentHistoryRepository.saveVersion).not.toHaveBeenCalled();
expect(trx.save).not.toHaveBeenCalled();
expect(runtimeCacheService.clearRuntimes).not.toHaveBeenCalled();
expect(chatIntegrationService.syncToConfig).not.toHaveBeenCalled();
expect(agent.activeVersionId).toBeNull();
});
it('checks every configured channel and skips draft entries, which have no credential', async () => {
const { service, agentRepository, chatIntegrationService } = makeService();
const draft = { type: 'slack', credentialId: '' } as const;
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({ integrations: [telegram, draft] }),
);
await service.publishAgent(agentId, projectId, user, byUser);
expect(chatIntegrationService.assertStartupPreconditions).toHaveBeenCalledTimes(1);
expect(chatIntegrationService.assertStartupPreconditions).toHaveBeenCalledWith(
agentId,
telegram,
projectId,
);
});
it('preflights a channel whose settings a later version made required', async () => {
// Whether the preflight re-runs a platform's own `validateConfig` is
// asserted against the real service in `chat-integration.service.test.ts`;
// here the point is only that a legacy entry still reaches the preflight.
const { service, agentRepository, chatIntegrationService } = makeService();
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({ integrations: [{ type: 'telegram', credentialId: 'cred-1' }] }),
);
await expect(service.publishAgent(agentId, projectId, user, byUser)).resolves.toBeDefined();
expect(chatIntegrationService.assertStartupPreconditions).toHaveBeenCalledWith(
agentId,
{ type: 'telegram', credentialId: 'cred-1' },
projectId,
);
});
it('runs before the version is written, so a rejection cannot leave a half-publish', async () => {
const { service, agentRepository, chatIntegrationService, agentHistoryRepository } =
makeService();
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({ integrations: [telegram] }),
);
chatIntegrationService.assertStartupPreconditions.mockImplementation(async () => {
expect(agentHistoryRepository.saveVersion).not.toHaveBeenCalled();
});
await service.publishAgent(agentId, projectId, user, byUser);
expect(agentHistoryRepository.saveVersion).toHaveBeenCalled();
});
});
it('rejects publishing a specific version when its snapshot fails validation, without touching the current draft validator', async () => {
const { service, agentRepository, agentHistoryRepository, agentValidationService } =
makeService();
@@ -2,7 +2,7 @@ import {
AgentConnectIntegrationDto,
AgentDisconnectIntegrationDto,
type AgentDisconnectIntegrationResponse,
isDraftIntegration,
type AgentIntegrationConnectResponse,
type AgentIntegrationStatusResponse,
} from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
@@ -10,9 +10,12 @@ import { Body, Get, Param, Post, ProjectScope, RestController } from '@n8n/decor
import type { Request, Response } from 'express';
import { AgentIntegrationManagementService } from './agent-integration-management.service';
import { AgentChannelStatusReporter } from './integrations/agent-channel-status-reporter';
import { ChatIntegrationRegistry } from './integrations/agent-chat-integration';
import { buildChannelStatusReport } from './integrations/channel-status-report';
import { ChatIntegrationService } from './integrations/chat-integration.service';
import { channelIntegrationRecorder } from './integrations/recording/channel-integration-recorder';
import { AgentChannelStatusRepository } from './repositories/agent-channel-status.repository';
import { AgentRepository } from './repositories/agent.repository';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@@ -24,6 +27,8 @@ export class AgentIntegrationsController {
private readonly chatIntegrationService: ChatIntegrationService,
private readonly agentRepository: AgentRepository,
private readonly chatIntegrationRegistry: ChatIntegrationRegistry,
private readonly channelStatusRepository: AgentChannelStatusRepository,
private readonly statusReporter: AgentChannelStatusReporter,
) {}
@Post('/:agentId/integrations/connect')
@@ -33,7 +38,7 @@ export class AgentIntegrationsController {
_res: Response,
@Param('agentId') agentId: string,
@Body payload: AgentConnectIntegrationDto,
) {
): Promise<AgentIntegrationConnectResponse> {
await this.integrationManagementService.validateConfig(req.body);
const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
@@ -82,26 +87,12 @@ export class AgentIntegrationsController {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
// Draft entries (`credentialId: ''`) written during the initial build so
// the panel can show a needs-setup chip aren't a real connection — report
// them as disconnected so channel-setup UIs don't render an already-
// connected state and hide their own setup form.
const chatIntegrations = (agent.integrations ?? [])
.filter((i) => !isDraftIntegration(i))
.map((i) => ({
type: i.type,
credentialId: i.credentialId,
...('settings' in i ? { settings: i.settings } : {}),
}));
return {
status:
chatIntegrations.length === 0
? 'disconnected'
: agent.activeVersionId === null
? 'configured'
: 'connected',
integrations: chatIntegrations,
};
const statuses = await this.channelStatusRepository.findByAgentId(agentId);
const now = new Date();
return buildChannelStatusReport(agent.integrations, agent.activeVersionId, statuses, (row) =>
this.statusReporter.isLive(row, now),
);
}
// Third-party webhook callback: do not add @ProjectScope. Auth happens
@@ -1,4 +1,5 @@
import {
isDraftIntegration,
type AgentConfigValidationResponse,
type AgentJsonConfig,
type AgentSkill,
@@ -282,9 +283,32 @@ export class AgentPublishService {
);
requireValidValidation(validation);
await this.assertChannelsStartable(agent, projectId);
return validation;
}
/**
* Reject a publish whose channels cannot start for a reason only the user can
* fix — today, a credential another agent already claims.
*
* This runs before the version is written, so a rejection leaves nothing
* behind: the agent stays unpublished and there is no partial state to roll
* back. Only deterministic checks belong here — startup failures that a retry
* can clear are reported per channel and healed by the reconciler instead, so
* an unreachable platform never blocks a publish.
*
* Draft entries carry no credential to check; validation has already rejected
* them by this point, and skipping them keeps that the single place that owns
* the rule.
*/
private async assertChannelsStartable(agent: Agent, projectId: string): Promise<void> {
const chatIntegrationService = Container.get(ChatIntegrationService);
for (const integration of agent.integrations ?? []) {
if (isDraftIntegration(integration)) continue;
await chatIntegrationService.assertStartupPreconditions(agent.id, integration, projectId);
}
}
async unpublishAgent(
agentId: string,
projectId: string,
@@ -92,20 +92,24 @@ export class AgentsModule implements ModuleInterface {
registry.register(Container.get(DiscordIntegration));
registry.register(Container.get(N8nChatIntegration));
// Reconnect Chat and Task services on startup so this main resumes its
// integrations and tasks for the role it currently holds.
// Resume Chat and Task services on startup so this main runs what its
// current role calls for.
//
// Chat integrations run on every main: webhook-driven platforms (Slack,
// Linear, Telegram in webhook mode) need to be connected on every main
// because inbound webhooks are load-balanced. Polling-driven integrations
// (Telegram in polling mode) are filtered to leader-only inside the
// service via `AgentChatIntegration.requiresLeader()`.
// Chat channels are reconciled on a loop rather than reconnected once:
// startup is only the first pass, and every later pass is what lets a
// channel that failed to start recover without a republish. Webhook-driven
// platforms (Slack, Linear, Telegram in webhook mode) run on every main
// because inbound webhooks are load-balanced; polling-driven ones
// (Telegram in polling mode) are filtered to the leader via
// `AgentChatIntegration.requiresLeader()`.
//
// Tasks remain leader-only by design — a cron firing on multiple
// mains would run the agent twice for the same tick.
const { ChatIntegrationService } = await import('./integrations/chat-integration.service.js');
const { AgentChannelReconciler } = await import(
'./integrations/agent-channel-reconciler.service.js'
);
const { AgentTaskService } = await import('./agent-task.service.js');
const chatService = Container.get(ChatIntegrationService);
const channelReconciler = Container.get(AgentChannelReconciler);
const taskService = Container.get(AgentTaskService);
const logger = Container.get(Logger);
const instanceSettings = Container.get(InstanceSettings);
@@ -127,11 +131,14 @@ export class AgentsModule implements ModuleInterface {
);
this.interruptedExecutionSweepTimer.unref();
}
void chatService.reconnectAll().catch((error) => {
logger.error('[Agents] Failed to reconnect integrations on startup', {
error: error instanceof Error ? error.message : String(error),
});
});
// Workers never receive inbound platform events: no webhook route, no polling
// loop. Holding channels there would connect adapters nothing reads and, now
// that startups are reported, publish status rows for a process that cannot
// serve the channel either way. Webhook instances do serve the agent webhook
// route, so they keep their channels.
if (instanceSettings.instanceType !== 'worker') {
channelReconciler.init();
}
if (instanceSettings.isLeader) {
void taskService.reconnectAll().catch((error) => {
logger.error('[Agents] Failed to reconnect tasks on startup', {
@@ -169,6 +176,7 @@ export class AgentsModule implements ModuleInterface {
const { AgentFile } = await import('./entities/agent-file.entity.js');
const { AgentChatAttachment } = await import('./entities/agent-chat-attachment.entity.js');
const { AgentChatSubscription } = await import('./entities/agent-chat-subscription.entity.js');
const { AgentChannelStatus } = await import('./entities/agent-channel-status.entity.js');
const { AgentCheckpoint } = await import('./entities/agent-checkpoint.entity.js');
const { AgentResourceEntity } = await import('./entities/agent-resource.entity.js');
const { AgentThreadEntity } = await import('./entities/agent-thread.entity.js');
@@ -205,6 +213,7 @@ export class AgentsModule implements ModuleInterface {
AgentFile,
AgentChatAttachment,
AgentChatSubscription,
AgentChannelStatus,
AgentCheckpoint,
AgentResourceEntity,
AgentThreadEntity,
@@ -0,0 +1,117 @@
import { DateTimeColumn, WithTimestamps } from '@n8n/db';
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
type Relation,
} from '@n8n/typeorm';
import { Agent } from './agent.entity';
/** Outcome of the last attempt one process made to start a channel. */
export type AgentChannelStatusValue = 'connected' | 'error';
/**
* What one process observed the last time it tried to start one channel.
* Without it, a channel that failed to start is indistinguishable from a
* running one, because the API can otherwise only infer "connected" from the
* config plus the active version.
*
* Keyed by `hostId` as well as by channel, so **every row has exactly one
* writer**. Webhook channels run on every main and each main succeeds or fails
* on its own; sharing a row between them would mean each write contradicting
* the last, and a reported status that flips on every pass. Per-process rows
* make the reported status a pure function of the rows, so it only changes when
* something really did. It also makes the retry counters below honest: a
* throttle on this process's attempts, not a counter several processes race on.
*
* A reader combines the rows (see `buildChannelStatusReport`): any live row
* reporting an error means the channel is degraded, because a main that cannot
* start it cannot serve it either.
*/
@Entity({ name: 'agent_channel_status' })
export class AgentChannelStatus extends WithTimestamps {
@PrimaryColumn({
type: 'varchar',
length: 36,
comment: 'Agent that owns this channel',
})
agentId: string;
@ManyToOne(() => Agent, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'agentId' })
agent: Relation<Agent>;
@PrimaryColumn({
type: 'varchar',
length: 64,
comment: 'Chat integration platform for this channel',
})
integrationType: string;
@PrimaryColumn({
type: 'varchar',
length: 36,
comment:
'Credential connection that backs this channel; no FK so a failure is still recordable after the credential is deleted',
})
credentialId: string;
/** Indexed: every reconciliation pass reads this instance's own rows. */
@Index()
@PrimaryColumn({
type: 'varchar',
length: 128,
comment: 'Process that observed this; the only writer of this row',
})
hostId: string;
@Column({
type: 'varchar',
length: 16,
comment: 'What this process last observed: connected or error',
})
status: AgentChannelStatusValue;
@Column({
type: 'text',
nullable: true,
comment: 'Why this process could not start the channel; null once it succeeds',
})
errorMessage: string | null;
@Column({
type: 'int',
default: 0,
comment: 'Consecutive failed startup attempts by this process, reset on success',
})
attempts: number;
/**
* Separate from `updatedAt` on purpose: a heartbeat moves `updatedAt`, and a
* retry deadline that moved with it would bring every retry forward.
*/
@DateTimeColumn({
nullable: true,
comment: 'Earliest this process should retry; null when there is nothing to retry',
})
backoffUntil: Date | null;
/**
* A process cannot delete its own rows when it crashes, and `hostId` is
* regenerated on restart, so a later process cannot recognise them either.
* The owner therefore keeps this ahead of now while it is alive, readers
* ignore rows past it, and the leader deletes them. Null means no reconciler
* is running to refresh it, so the row is the only account there is and is
* never treated as stale.
*/
@Index()
@DateTimeColumn({
nullable: true,
comment: 'When this row stops counting unless its owner refreshes it; null never expires',
})
expiresAt: Date | null;
}
@@ -0,0 +1,789 @@
import type { AgentIntegrationConfig } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import { mockLogger } from '@n8n/backend-test-utils';
import { AgentsConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import type { ErrorReporter, InstanceSettings } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import type { Agent } from '../../entities/agent.entity';
import type { AgentChannelStatus } from '../../entities/agent-channel-status.entity';
import type {
AgentChannelRef,
AgentChannelStatusRepository,
} from '../../repositories/agent-channel-status.repository';
import type { AgentRepository } from '../../repositories/agent.repository';
import { AgentChannelReconciler } from '../agent-channel-reconciler.service';
import { AgentChannelStatusReporter } from '../agent-channel-status-reporter';
import { AgentChatIntegration, ChatIntegrationRegistry } from '../agent-chat-integration';
import type { ChatIntegrationService } from '../chat-integration.service';
const RECONCILE_INTERVAL_SECONDS = 60;
const HOST_ID = 'main-this-one';
class FakeIntegration extends AgentChatIntegration {
constructor(
readonly type: string,
private readonly leaderOnly: boolean,
) {
super();
}
readonly credentialTypes = ['fake'];
readonly displayLabel = 'Fake';
readonly displayIcon = 'zap';
override requiresLeader(): boolean {
return this.leaderOnly;
}
async createAdapter(): Promise<unknown> {
return {};
}
}
const slack: AgentIntegrationConfig = { type: 'slack', credentialId: 'cred-slack' };
const telegram: AgentIntegrationConfig = { type: 'telegram', credentialId: 'cred-telegram' };
function makeAgent(integrations: AgentIntegrationConfig[], id = 'agent-1'): Agent {
return { id, projectId: 'project-1', integrations } as unknown as Agent;
}
function refOf(integration: AgentIntegrationConfig, agentId = 'agent-1'): AgentChannelRef {
return { agentId, integrationType: integration.type, credentialId: integration.credentialId };
}
function ownRow(
integration: AgentIntegrationConfig,
overrides: Partial<AgentChannelStatus> = {},
): AgentChannelStatus {
return {
...refOf(integration),
hostId: HOST_ID,
status: 'connected',
errorMessage: null,
attempts: 0,
backoffUntil: null,
expiresAt: new Date(Date.now() + 3 * RECONCILE_INTERVAL_SECONDS * Time.seconds.toMilliseconds),
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as AgentChannelStatus;
}
function erroredOwnRow(
integration: AgentIntegrationConfig,
overrides: Partial<AgentChannelStatus> = {},
): AgentChannelStatus {
return ownRow(integration, {
status: 'error',
errorMessage: 'boom',
attempts: 2,
...overrides,
});
}
function build(
opts: {
isLeader?: boolean;
live?: AgentChannelRef[];
intervalSeconds?: number;
logger?: Logger;
} = {},
) {
const registry = new ChatIntegrationRegistry();
// Telegram stands in for a polling platform: exactly one main may own it.
registry.register(new FakeIntegration('telegram', true));
registry.register(new FakeIntegration('slack', false));
const agentRepository = mock<AgentRepository>();
agentRepository.findPublished.mockResolvedValue([]);
const channelStatusRepository = mock<AgentChannelStatusRepository>();
channelStatusRepository.findOwnAll.mockResolvedValue([]);
channelStatusRepository.deleteExpired.mockResolvedValue(0);
const chatIntegrationService = mock<ChatIntegrationService>();
const live = opts.live ?? [];
chatIntegrationService.listLiveChannels.mockReturnValue(live);
chatIntegrationService.hasLiveChannel.mockImplementation((ref) =>
live.some(
(candidate) =>
candidate.agentId === ref.agentId &&
candidate.integrationType === ref.integrationType &&
candidate.credentialId === ref.credentialId,
),
);
const agentsConfig = Object.assign(new AgentsConfig(), {
channelReconcileIntervalSeconds: opts.intervalSeconds ?? RECONCILE_INTERVAL_SECONDS,
});
// The real reporter, so backoff and lease policy are exercised rather than
// mocked away; only the persistence under it is a double.
const statusReporter = new AgentChannelStatusReporter(
mockLogger(),
agentsConfig,
channelStatusRepository,
);
const errorReporter = mock<ErrorReporter>();
const logger = opts.logger ?? mockLogger();
// Mutable, so a test can promote this main between passes the way a real
// takeover does.
const role = { isLeader: opts.isLeader ?? true };
const instanceSettings = {
hostId: HOST_ID,
get isLeader() {
return role.isLeader;
},
} as InstanceSettings;
const reconciler = new AgentChannelReconciler(
logger,
agentsConfig,
agentRepository,
channelStatusRepository,
statusReporter,
chatIntegrationService,
registry,
instanceSettings,
errorReporter,
);
return {
reconciler,
agentRepository,
channelStatusRepository,
chatIntegrationService,
statusReporter,
errorReporter,
role,
};
}
describe('AgentChannelReconciler', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('starting what should be running', () => {
it('starts a published channel that is not running', async () => {
const { reconciler, agentRepository, chatIntegrationService } = build();
const agent = makeAgent([slack]);
agentRepository.findPublished.mockResolvedValue([agent]);
await reconciler.reconcile('interval');
expect(chatIntegrationService.startChannel).toHaveBeenCalledWith(agent, slack);
});
it('never tries to start a draft entry, which has no credential', async () => {
const { reconciler, agentRepository, chatIntegrationService } = build();
agentRepository.findPublished.mockResolvedValue([
makeAgent([{ type: 'discord', credentialId: '' }]),
]);
await reconciler.reconcile('interval');
expect(chatIntegrationService.startChannel).not.toHaveBeenCalled();
});
it('scrubs credential material out of what it logs about a failure', async () => {
// A failed Telegram request quotes the API URL, and the bot token is in that
// path — the same reason `recordFailure` scrubs the message it persists.
const logger = mock<Logger>();
const { reconciler, agentRepository, chatIntegrationService } = build({ logger });
agentRepository.findPublished.mockResolvedValue([makeAgent([telegram])]);
chatIntegrationService.startChannel.mockRejectedValue(
new Error(
'request to https://api.telegram.org/bot123456789:AAFakeTokenValueForTestingOnly12345/setWebhook failed',
),
);
await reconciler.reconcile('interval');
const logged = logger.warn.mock.calls.at(-1)?.[1] as { error: string };
expect(logged.error).not.toContain('AAFakeTokenValueForTestingOnly12345');
expect(logged.error).toContain('setWebhook');
});
it('keeps going after one channel fails to start', async () => {
const { reconciler, agentRepository, chatIntegrationService } = build();
agentRepository.findPublished.mockResolvedValue([makeAgent([telegram, slack])]);
chatIntegrationService.startChannel.mockRejectedValueOnce(new Error('boom'));
await reconciler.reconcile('interval');
expect(chatIntegrationService.startChannel).toHaveBeenCalledTimes(2);
});
});
describe('standing behind a channel it is running', () => {
it('extends the lease on a row that already says connected', async () => {
const { reconciler, agentRepository, channelStatusRepository } = build({
live: [refOf(slack)],
});
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
channelStatusRepository.findOwnAll.mockResolvedValue([ownRow(slack)]);
await reconciler.reconcile('interval');
expect(channelStatusRepository.refreshOwnLease).toHaveBeenCalledWith(
refOf(slack),
expect.any(Date),
);
expect(channelStatusRepository.saveOwn).not.toHaveBeenCalled();
});
it('writes a row for a running channel nothing had reported yet', async () => {
// The state of every channel on an instance that just upgraded: live, but
// with no row, so it would otherwise be reported as still starting.
const { reconciler, agentRepository, channelStatusRepository } = build({
live: [refOf(slack)],
});
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
await reconciler.reconcile('interval');
expect(channelStatusRepository.saveOwn).toHaveBeenCalledWith(
refOf(slack),
expect.objectContaining({ status: 'connected', attempts: 0, backoffUntil: null }),
);
});
it('replaces its own stale error once the channel is up', async () => {
const { reconciler, agentRepository, channelStatusRepository } = build({
live: [refOf(slack)],
});
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
channelStatusRepository.findOwnAll.mockResolvedValue([erroredOwnRow(slack)]);
await reconciler.reconcile('interval');
expect(channelStatusRepository.saveOwn).toHaveBeenCalledWith(
refOf(slack),
expect.objectContaining({ status: 'connected', errorMessage: null }),
);
});
});
describe('retry backoff', () => {
it('keeps the error row alive while it waits out a long retry deadline', async () => {
// The backoff outgrows a lease from the third consecutive failure on. If the
// row expired mid-wait the sweep would delete it, dropping the reported
// reason and resetting the attempt count that grows the backoff.
const { reconciler, agentRepository, channelStatusRepository } = build();
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
channelStatusRepository.findOwnAll.mockResolvedValue([
erroredOwnRow(slack, {
attempts: 3,
backoffUntil: new Date(
Date.now() + 4 * RECONCILE_INTERVAL_SECONDS * Time.seconds.toMilliseconds,
),
}),
]);
await reconciler.reconcile('interval');
expect(channelStatusRepository.refreshOwnLease).toHaveBeenCalledWith(
refOf(slack),
expect.any(Date),
);
// Still a held-back retry, not a fresh attempt.
expect(channelStatusRepository.saveOwn).not.toHaveBeenCalled();
});
it('waits out a retry deadline that has not passed', async () => {
const { reconciler, agentRepository, channelStatusRepository, chatIntegrationService } =
build();
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
channelStatusRepository.findOwnAll.mockResolvedValue([
erroredOwnRow(slack, {
backoffUntil: new Date(Date.now() + 5 * Time.minutes.toMilliseconds),
}),
]);
await reconciler.reconcile('interval');
expect(chatIntegrationService.startChannel).not.toHaveBeenCalled();
});
it('retries once the deadline has passed', async () => {
const { reconciler, agentRepository, channelStatusRepository, chatIntegrationService } =
build();
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
channelStatusRepository.findOwnAll.mockResolvedValue([
erroredOwnRow(slack, {
backoffUntil: new Date(Date.now() - Time.seconds.toMilliseconds),
}),
]);
await reconciler.reconcile('interval');
expect(chatIntegrationService.startChannel).toHaveBeenCalledTimes(1);
});
it.each(['startup', 'leader-takeover'] as const)(
'ignores the deadline on a %s pass, because the cause may be gone',
async (reason) => {
const { reconciler, agentRepository, channelStatusRepository, chatIntegrationService } =
build();
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
channelStatusRepository.findOwnAll.mockResolvedValue([
erroredOwnRow(slack, {
backoffUntil: new Date(Date.now() + Time.hours.toMilliseconds),
}),
]);
await reconciler.reconcile(reason);
expect(chatIntegrationService.startChannel).toHaveBeenCalledTimes(1);
},
);
it('does not let a lease refresh bring a retry forward', async () => {
// A heartbeat moves `updatedAt`; only `backoffUntil` decides a retry.
const { reconciler, agentRepository, channelStatusRepository, chatIntegrationService } =
build({ live: [refOf(slack)] });
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
channelStatusRepository.findOwnAll.mockResolvedValue([ownRow(slack)]);
await reconciler.reconcile('interval');
expect(channelStatusRepository.refreshOwnLease).toHaveBeenCalled();
expect(chatIntegrationService.startChannel).not.toHaveBeenCalled();
});
});
describe('multi-main roles', () => {
it('does not start a leader-only channel on a follower', async () => {
const { reconciler, agentRepository, chatIntegrationService } = build({ isLeader: false });
agentRepository.findPublished.mockResolvedValue([makeAgent([telegram, slack])]);
await reconciler.reconcile('interval');
expect(chatIntegrationService.startChannel).toHaveBeenCalledTimes(1);
expect(chatIntegrationService.startChannel).toHaveBeenCalledWith(expect.anything(), slack);
});
it('releases a leader-only channel it holds as a follower', async () => {
const { reconciler, agentRepository, chatIntegrationService } = build({
isLeader: false,
live: [refOf(telegram)],
});
agentRepository.findPublished.mockResolvedValue([makeAgent([telegram])]);
await reconciler.reconcile('interval');
// Locally, so the main that just took the channel over keeps running it.
expect(chatIntegrationService.releaseChannelLocally).toHaveBeenCalledWith('agent-1', {
type: 'telegram',
credentialId: 'cred-telegram',
});
expect(chatIntegrationService.disconnect).not.toHaveBeenCalled();
});
it('never touches another instances rows, only its own and expired ones', async () => {
const { reconciler, agentRepository, channelStatusRepository } = build({
live: [refOf(slack)],
});
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
await reconciler.reconcile('interval');
// Reads are scoped to this host; the only cross-host write is the
// expiry sweep.
expect(channelStatusRepository.findOwnAll).toHaveBeenCalled();
expect(channelStatusRepository.find).not.toHaveBeenCalled();
expect(channelStatusRepository.findByAgentId).not.toHaveBeenCalled();
});
it('leaves the expiry sweep to the leader', async () => {
const { reconciler, channelStatusRepository } = build({ isLeader: false });
await reconciler.reconcile('interval');
expect(channelStatusRepository.deleteExpired).not.toHaveBeenCalled();
});
it('clears rows abandoned by instances that are gone', async () => {
const { reconciler, channelStatusRepository } = build({ isLeader: true });
channelStatusRepository.deleteExpired.mockResolvedValue(2);
await reconciler.reconcile('interval');
expect(channelStatusRepository.deleteExpired).toHaveBeenCalledWith(expect.any(Date));
});
});
describe('withdrawing what it no longer runs', () => {
it('releases a channel whose agent is no longer published', async () => {
const { reconciler, chatIntegrationService } = build({ live: [refOf(slack)] });
await reconciler.reconcile('interval');
expect(chatIntegrationService.releaseChannelLocally).toHaveBeenCalledWith('agent-1', {
type: 'slack',
credentialId: 'cred-slack',
});
});
it('drops its own row for a channel it is not running and should not', async () => {
// A failed startup, then the agent was unpublished: nothing live to tear
// down, so nothing would have withdrawn the row.
const { reconciler, channelStatusRepository } = build();
channelStatusRepository.findOwnAll.mockResolvedValue([erroredOwnRow(slack)]);
await reconciler.reconcile('interval');
expect(channelStatusRepository.clearOwnChannel).toHaveBeenCalledWith(refOf(slack));
});
it('keeps its own row for a channel it should still be running', async () => {
const { reconciler, agentRepository, channelStatusRepository } = build();
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
channelStatusRepository.findOwnAll.mockResolvedValue([erroredOwnRow(slack)]);
await reconciler.reconcile('interval');
expect(channelStatusRepository.clearOwnChannel).not.toHaveBeenCalled();
});
});
describe('the loop itself', () => {
it('schedules no repeating pass when the interval is zero', () => {
const { reconciler } = build({ intervalSeconds: 0 });
const setIntervalSpy = vi.spyOn(global, 'setInterval');
reconciler.init();
expect(setIntervalSpy).not.toHaveBeenCalled();
setIntervalSpy.mockRestore();
});
it('still starts channels on boot when the interval is zero', async () => {
// Turning the loop off gives up retries, not the channels themselves: this
// pass is the only thing that starts them on this main.
const { reconciler, agentRepository, chatIntegrationService } = build({
intervalSeconds: 0,
});
const agent = makeAgent([slack]);
agentRepository.findPublished.mockResolvedValue([agent]);
reconciler.init();
// `init` fires the boot pass without awaiting it, so wait for it to land.
await vi.waitFor(() =>
expect(chatIntegrationService.startChannel).toHaveBeenCalledWith(agent, slack),
);
});
it('still claims leader-only channels on takeover when the interval is zero', async () => {
// A follower leaves a polling channel alone, so the boot pass starts
// nothing and only the takeover can — which is what the hook is for.
const { reconciler, agentRepository, chatIntegrationService, role } = build({
intervalSeconds: 0,
isLeader: false,
});
const agent = makeAgent([telegram]);
agentRepository.findPublished.mockResolvedValue([agent]);
reconciler.init();
await vi.waitFor(() => expect(agentRepository.findPublished).toHaveBeenCalled());
expect(chatIntegrationService.startChannel).not.toHaveBeenCalled();
role.isLeader = true;
await reconciler.reconcileOnLeaderTakeover();
expect(chatIntegrationService.startChannel).toHaveBeenCalledWith(agent, telegram);
});
it('stays inert on takeover when it was never initialized', async () => {
// A worker never calls `init`, so it must not start channels.
const { reconciler, agentRepository } = build();
await reconciler.reconcileOnLeaderTakeover();
expect(agentRepository.findPublished).not.toHaveBeenCalled();
});
it('reports a failing pass instead of letting it kill the interval', async () => {
const { reconciler, agentRepository, errorReporter } = build();
const failure = new Error('database is down');
agentRepository.findPublished.mockRejectedValue(failure);
await expect(reconciler.reconcile('interval')).resolves.toBeUndefined();
// Swallowed for the interval's sake, not silenced: the cause still has to
// reach telemetry and the log.
expect(errorReporter.error).toHaveBeenCalledWith(failure, { shouldBeLogged: true });
});
it('withdraws everything it said on shutdown, so a restart is not read as degraded', async () => {
const { reconciler, channelStatusRepository } = build();
await reconciler.shutdown();
expect(channelStatusRepository.clearOwnHost).toHaveBeenCalled();
});
it('stops working once shut down', async () => {
const { reconciler, agentRepository, chatIntegrationService } = build();
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
await reconciler.shutdown();
await reconciler.reconcile('interval');
expect(chatIntegrationService.startChannel).not.toHaveBeenCalled();
});
});
});
describe('AgentChannelReconciler — passes never overlap', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('drops a tick that lands while a pass is still running', async () => {
// Two passes would both see the channel as not running and both start it,
// and the second would tear down what the first just built.
const { reconciler, agentRepository, chatIntegrationService } = build();
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
// Held open from outside, so the second call is made while the first pass is
// provably still inside `startChannel`.
let release!: () => void;
const held = new Promise<void>((resolve) => (release = resolve));
let entered!: () => void;
const inStartChannel = new Promise<void>((resolve) => (entered = resolve));
chatIntegrationService.startChannel.mockImplementation(async () => {
entered();
await held;
});
const first = reconciler.reconcile('interval');
await inStartChannel;
const second = reconciler.reconcile('interval');
release();
await Promise.all([first, second]);
expect(chatIntegrationService.startChannel).toHaveBeenCalledTimes(1);
});
it('waits for a pass in flight before withdrawing on shutdown', async () => {
// Otherwise the pass's own `recordConnected` lands after the withdrawal and
// leaves a row behind for a lease's worth of time.
const { reconciler, agentRepository, channelStatusRepository, chatIntegrationService } =
build();
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
let release!: () => void;
const held = new Promise<void>((resolve) => (release = resolve));
let entered!: () => void;
const inStartChannel = new Promise<void>((resolve) => (entered = resolve));
chatIntegrationService.startChannel.mockImplementation(async () => {
entered();
await held;
});
const pass = reconciler.reconcile('interval');
await inStartChannel;
const shutdown = reconciler.shutdown();
expect(channelStatusRepository.clearOwnHost).not.toHaveBeenCalled();
release();
await Promise.all([pass, shutdown]);
expect(channelStatusRepository.clearOwnHost).toHaveBeenCalled();
});
it('does not start a leader-only channel when leadership was lost mid-pass', async () => {
const registry = new ChatIntegrationRegistry();
registry.register(new FakeIntegration('telegram', true));
const agentRepository = mock<AgentRepository>();
agentRepository.findPublished.mockResolvedValue([makeAgent([telegram])]);
const channelStatusRepository = mock<AgentChannelStatusRepository>();
channelStatusRepository.findOwnAll.mockResolvedValue([]);
channelStatusRepository.deleteExpired.mockResolvedValue(0);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.listLiveChannels.mockReturnValue([]);
chatIntegrationService.hasLiveChannel.mockReturnValue(false);
// Leader while the pass computes what it wants, follower by the time it
// would start anything.
let isLeader = true;
const instanceSettings = {
hostId: HOST_ID,
get isLeader() {
const current = isLeader;
isLeader = false;
return current;
},
} as InstanceSettings;
const agentsConfig = Object.assign(new AgentsConfig(), {
channelReconcileIntervalSeconds: RECONCILE_INTERVAL_SECONDS,
});
const reconciler = new AgentChannelReconciler(
mockLogger(),
agentsConfig,
agentRepository,
channelStatusRepository,
new AgentChannelStatusReporter(mockLogger(), agentsConfig, channelStatusRepository),
chatIntegrationService,
registry,
instanceSettings,
mock<ErrorReporter>(),
);
await reconciler.reconcile('interval');
expect(chatIntegrationService.startChannel).not.toHaveBeenCalled();
});
});
describe('AgentChannelReconciler — role changes while a pass is running', () => {
beforeEach(() => {
vi.clearAllMocks();
});
function heldStart(chatIntegrationService: ReturnType<typeof mock<ChatIntegrationService>>) {
let release!: () => void;
const held = new Promise<void>((resolve) => (release = resolve));
let entered!: () => void;
const inStartChannel = new Promise<void>((resolve) => (entered = resolve));
chatIntegrationService.startChannel.mockImplementation(async () => {
entered();
await held;
});
return { release: () => release(), inStartChannel };
}
it('still runs a takeover pass that arrived while another pass was running', async () => {
// The takeover carries the promotion; the running pass decided it was a
// follower before it. Only waiting would leave leader-only channels stopped
// until the next tick.
const { reconciler, agentRepository, chatIntegrationService } = build();
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
const { release, inStartChannel } = heldStart(chatIntegrationService);
const running = reconciler.reconcile('interval');
await inStartChannel;
// The takeover entry point is gated on the loop running, which `init` sets up;
// the queueing being tested here is in `reconcile` itself.
const takeover = reconciler.reconcile('leader-takeover');
release();
await Promise.all([running, takeover]);
// Once for the pass that was running, once for the queued takeover.
expect(agentRepository.findPublished).toHaveBeenCalledTimes(2);
});
it('withdraws its own row for a leader-only channel it lost mid-pass', async () => {
// `forgetOwnOrphans` reads the snapshot taken before the stepdown, so
// without this the row would keep claiming the channel for this instance.
const registry = new ChatIntegrationRegistry();
registry.register(new FakeIntegration('telegram', true));
const agentRepository = mock<AgentRepository>();
agentRepository.findPublished.mockResolvedValue([makeAgent([telegram])]);
const channelStatusRepository = mock<AgentChannelStatusRepository>();
channelStatusRepository.findOwnAll.mockResolvedValue([]);
channelStatusRepository.deleteExpired.mockResolvedValue(0);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.listLiveChannels.mockReturnValue([]);
chatIntegrationService.hasLiveChannel.mockReturnValue(false);
let isLeader = true;
const instanceSettings = {
hostId: HOST_ID,
get isLeader() {
const current = isLeader;
isLeader = false;
return current;
},
} as InstanceSettings;
const agentsConfig = Object.assign(new AgentsConfig(), {
channelReconcileIntervalSeconds: RECONCILE_INTERVAL_SECONDS,
});
const reconciler = new AgentChannelReconciler(
mockLogger(),
agentsConfig,
agentRepository,
channelStatusRepository,
new AgentChannelStatusReporter(mockLogger(), agentsConfig, channelStatusRepository),
chatIntegrationService,
registry,
instanceSettings,
mock<ErrorReporter>(),
);
await reconciler.reconcile('interval');
expect(chatIntegrationService.startChannel).not.toHaveBeenCalled();
expect(channelStatusRepository.clearOwnChannel).toHaveBeenCalledWith(refOf(telegram));
});
it('does not report a startup that finished after shutdown stopped waiting', async () => {
// Past the bound the rows are already withdrawn, so a write landing
// afterwards would report this host as running the channel until its lease
// expired — with the process gone and nothing left to refresh or correct it.
vi.useFakeTimers();
try {
const {
reconciler,
agentRepository,
channelStatusRepository,
chatIntegrationService,
statusReporter,
} = build();
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
let finishStartup!: () => void;
const startupDone = new Promise<void>((resolve) => (finishStartup = resolve));
// Mirrors `connect`, which reports from inside the startup, so the write
// lands whenever the platform finally answers.
chatIntegrationService.startChannel.mockImplementation(async () => {
await startupDone;
await statusReporter.recordConnected(refOf(slack));
});
const pass = reconciler.reconcile('interval');
await vi.advanceTimersByTimeAsync(0);
const shutdown = reconciler.shutdown();
await vi.advanceTimersByTimeAsync(6 * Time.seconds.toMilliseconds);
await shutdown;
expect(channelStatusRepository.clearOwnHost).toHaveBeenCalled();
finishStartup();
await pass;
expect(channelStatusRepository.saveOwn).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('gives up waiting on a stalled pass rather than blocking shutdown', async () => {
vi.useFakeTimers();
try {
const { reconciler, agentRepository, channelStatusRepository, chatIntegrationService } =
build();
agentRepository.findPublished.mockResolvedValue([makeAgent([slack])]);
// Never resolves: a platform that accepted the connection and went quiet.
chatIntegrationService.startChannel.mockImplementation(
async () => await new Promise<void>(() => {}),
);
void reconciler.reconcile('interval');
await vi.advanceTimersByTimeAsync(0);
const shutdown = reconciler.shutdown();
await vi.advanceTimersByTimeAsync(6 * Time.seconds.toMilliseconds);
await shutdown;
expect(channelStatusRepository.clearOwnHost).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,266 @@
import { mockLogger } from '@n8n/backend-test-utils';
import { AgentsConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import { mock } from 'vitest-mock-extended';
import type { AgentChannelStatus } from '../../entities/agent-channel-status.entity';
import type { AgentChannelStatusRepository } from '../../repositories/agent-channel-status.repository';
import { AgentChannelStatusReporter } from '../agent-channel-status-reporter';
const ref = { agentId: 'agent-1', integrationType: 'slack', credentialId: 'cred-1' };
const INTERVAL_SECONDS = 60;
function build(intervalSeconds = INTERVAL_SECONDS) {
const repository = mock<AgentChannelStatusRepository>();
const agentsConfig = Object.assign(new AgentsConfig(), {
channelReconcileIntervalSeconds: intervalSeconds,
});
const reporter = new AgentChannelStatusReporter(mockLogger(), agentsConfig, repository);
return { reporter, repository };
}
function savedObservation(repository: ReturnType<typeof mock<AgentChannelStatusRepository>>) {
return repository.saveOwn.mock.calls[0][1];
}
describe('AgentChannelStatusReporter', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('recording what this instance observed', () => {
it('records a running channel with nothing to retry', async () => {
const { reporter, repository } = build();
await reporter.recordConnected(ref);
expect(repository.saveOwn).toHaveBeenCalledWith(ref, {
status: 'connected',
errorMessage: null,
attempts: 0,
backoffUntil: null,
expiresAt: expect.any(Date),
});
});
it('records a failure with its cause and a retry deadline', async () => {
const { reporter, repository } = build();
repository.findOwnChannel.mockResolvedValue(null);
await reporter.recordFailure(ref, new Error('Credential cred-1 not found'));
expect(savedObservation(repository)).toMatchObject({
status: 'error',
errorMessage: 'Credential cred-1 not found',
attempts: 1,
});
expect(savedObservation(repository).backoffUntil).toBeInstanceOf(Date);
});
it('counts consecutive failures of this instance', async () => {
const { reporter, repository } = build();
repository.findOwnChannel.mockResolvedValue({
status: 'error',
attempts: 4,
} as AgentChannelStatus);
await reporter.recordFailure(ref, new Error('boom'));
expect(savedObservation(repository)).toMatchObject({ attempts: 5 });
});
it('starts counting again after a success', async () => {
const { reporter, repository } = build();
repository.findOwnChannel.mockResolvedValue({
status: 'connected',
attempts: 0,
} as AgentChannelStatus);
await reporter.recordFailure(ref, new Error('boom'));
expect(savedObservation(repository)).toMatchObject({ attempts: 1 });
});
it('scrubs credential material out of the message it persists', async () => {
// A failed Telegram request quotes the API URL, and the bot token is in
// that path. This message is stored and served to the UI.
const { reporter, repository } = build();
repository.findOwnChannel.mockResolvedValue(null);
await reporter.recordFailure(
ref,
new Error(
'request to https://api.telegram.org/bot123456789:AAFakeTokenValueForTestingOnly12345/setWebhook failed',
),
);
const { errorMessage } = savedObservation(repository);
expect(errorMessage).not.toContain('AAFakeTokenValueForTestingOnly12345');
expect(errorMessage).toContain('setWebhook');
});
it('describes a non-Error cause rather than dropping it', async () => {
const { reporter, repository } = build();
repository.findOwnChannel.mockResolvedValue(null);
await reporter.recordFailure(ref, 'adapter exploded');
expect(savedObservation(repository)).toMatchObject({ errorMessage: 'adapter exploded' });
});
});
describe('retry backoff', () => {
it('grows the wait with each consecutive failure', async () => {
const { reporter, repository } = build();
const deadlineAfter = async (attempts: number) => {
repository.saveOwn.mockClear();
repository.findOwnChannel.mockResolvedValue({
status: 'error',
attempts: attempts - 1,
} as AgentChannelStatus);
await reporter.recordFailure(ref, new Error('boom'));
const { backoffUntil } = savedObservation(repository);
return backoffUntil!.getTime() - Date.now();
};
const first = await deadlineAfter(1);
const third = await deadlineAfter(3);
expect(first).toBeLessThanOrEqual(INTERVAL_SECONDS * Time.seconds.toMilliseconds);
expect(third).toBeGreaterThan(first);
});
it('caps the wait so a user who fixes the cause is not left waiting', async () => {
const { reporter, repository } = build();
repository.findOwnChannel.mockResolvedValue({
status: 'error',
attempts: 40,
} as AgentChannelStatus);
await reporter.recordFailure(ref, new Error('boom'));
const { backoffUntil } = savedObservation(repository);
expect(backoffUntil!.getTime() - Date.now()).toBeLessThanOrEqual(
10 * Time.minutes.toMilliseconds,
);
});
it('treats a channel with no deadline as ready', () => {
const { reporter } = build();
expect(reporter.isRetryReady(undefined, new Date())).toBe(true);
expect(reporter.isRetryReady({ backoffUntil: null }, new Date())).toBe(true);
});
it('holds a channel back until its deadline passes', () => {
const { reporter } = build();
const now = new Date('2026-01-01T00:00:00.000Z');
expect(
reporter.isRetryReady({ backoffUntil: new Date('2026-01-01T00:00:01.000Z') }, now),
).toBe(false);
expect(
reporter.isRetryReady({ backoffUntil: new Date('2025-12-31T23:59:59.000Z') }, now),
).toBe(true);
});
});
describe('leases', () => {
it('sets an expiry a few reconcile intervals out, so a missed pass is survivable', async () => {
const { reporter, repository } = build();
await reporter.recordConnected(ref);
const { expiresAt } = savedObservation(repository);
const aheadMs = expiresAt!.getTime() - Date.now();
expect(aheadMs).toBeGreaterThan(INTERVAL_SECONDS * Time.seconds.toMilliseconds);
});
it('sets no expiry when reconciliation is off, because nothing would refresh it', async () => {
const { reporter, repository } = build(0);
await reporter.recordConnected(ref);
expect(savedObservation(repository)).toMatchObject({ expiresAt: null });
});
it('treats a row with no expiry as live', () => {
const { reporter } = build();
expect(reporter.isLive({ expiresAt: null }, new Date())).toBe(true);
});
it('treats a row past its expiry as gone', () => {
const { reporter } = build();
const now = new Date('2026-01-01T00:00:00.000Z');
expect(reporter.isLive({ expiresAt: new Date('2025-12-31T23:59:59.000Z') }, now)).toBe(false);
expect(reporter.isLive({ expiresAt: new Date('2026-01-01T00:00:01.000Z') }, now)).toBe(true);
});
it('refreshes a lease without touching the retry deadline', async () => {
const { reporter, repository } = build();
await reporter.refreshLease(ref);
expect(repository.refreshOwnLease).toHaveBeenCalledWith(ref, expect.any(Date));
expect(repository.saveOwn).not.toHaveBeenCalled();
});
});
describe('withdrawing on the way out', () => {
it.each([
['recordConnected', async (r: AgentChannelStatusReporter) => await r.recordConnected(ref)],
[
'recordFailure',
async (r: AgentChannelStatusReporter) => await r.recordFailure(ref, new Error('boom')),
],
['refreshLease', async (r: AgentChannelStatusReporter) => await r.refreshLease(ref)],
])('ignores a late %s, so nothing is left behind for a lease', async (_name, call) => {
// A startup that shutdown stopped waiting for finishes after the
// withdrawal and reports what it found. Writing it would leave the channel
// reported against an instance that is gone until its lease expires.
const { reporter, repository } = build();
await reporter.withdrawAll();
await call(reporter);
expect(repository.saveOwn).not.toHaveBeenCalled();
expect(repository.refreshOwnLease).not.toHaveBeenCalled();
});
it('seals even when clearing the rows fails, because the process is still leaving', async () => {
const { reporter, repository } = build();
repository.clearOwnHost.mockRejectedValue(new Error('database is down'));
await reporter.withdrawAll();
await reporter.recordConnected(ref);
expect(repository.saveOwn).not.toHaveBeenCalled();
});
});
describe('never failing the operation it reports on', () => {
it.each([
['recordConnected', async (r: AgentChannelStatusReporter) => await r.recordConnected(ref)],
[
'recordFailure',
async (r: AgentChannelStatusReporter) => await r.recordFailure(ref, new Error('boom')),
],
['refreshLease', async (r: AgentChannelStatusReporter) => await r.refreshLease(ref)],
['withdraw', async (r: AgentChannelStatusReporter) => await r.withdraw(ref)],
['withdrawAll', async (r: AgentChannelStatusReporter) => await r.withdrawAll()],
])('swallows a database failure in %s', async (_name, call) => {
const { reporter, repository } = build();
const down = new Error('database is down');
repository.saveOwn.mockRejectedValue(down);
repository.findOwnChannel.mockRejectedValue(down);
repository.refreshOwnLease.mockRejectedValue(down);
repository.clearOwnChannel.mockRejectedValue(down);
repository.clearOwnHost.mockRejectedValue(down);
await expect(call(reporter)).resolves.toBeUndefined();
});
});
});
@@ -0,0 +1,290 @@
import type { AgentIntegrationConfig } from '@n8n/api-types';
import type { AgentChannelStatus } from '../../entities/agent-channel-status.entity';
import { buildChannelStatusReport } from '../channel-status-report';
const PUBLISHED = 'version-1';
const slack: AgentIntegrationConfig = { type: 'slack', credentialId: 'cred-slack' };
const telegram: AgentIntegrationConfig = {
type: 'telegram',
credentialId: 'cred-telegram',
settings: { accessMode: 'public', allowedUsers: [] },
};
/** Live unless a test says otherwise — expiry is exercised on its own below. */
const isLive = (row: AgentChannelStatus) =>
row.expiresAt === null || row.expiresAt.getTime() > Date.now();
function row(
integration: AgentIntegrationConfig,
hostId: string,
overrides: Partial<AgentChannelStatus> = {},
): AgentChannelStatus {
return {
agentId: 'agent-1',
integrationType: integration.type,
credentialId: integration.credentialId,
hostId,
status: 'connected',
errorMessage: null,
attempts: 0,
backoffUntil: null,
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as AgentChannelStatus;
}
function erroredRow(
integration: AgentIntegrationConfig,
hostId: string,
message: string,
overrides: Partial<AgentChannelStatus> = {},
): AgentChannelStatus {
return row(integration, hostId, {
status: 'error',
errorMessage: message,
attempts: 2,
...overrides,
});
}
describe('buildChannelStatusReport', () => {
it('reports no channels as disconnected', () => {
expect(buildChannelStatusReport([], PUBLISHED, [], isLive)).toEqual({
status: 'disconnected',
integrations: [],
});
});
it('reports every channel of an unpublished agent as configured, whatever the rows say', () => {
// A row can outlive an unpublish that failed to clear it; the agent still
// must not be reported as receiving events.
const report = buildChannelStatusReport(
[slack],
null,
[erroredRow(slack, 'main-a', 'boom')],
isLive,
);
expect(report.status).toBe('configured');
expect(report.integrations).toEqual([
{ type: 'slack', credentialId: 'cred-slack', status: 'configured' },
]);
});
it('reports a channel with no recorded attempt as starting, not connected', () => {
const report = buildChannelStatusReport([slack], PUBLISHED, [], isLive);
expect(report.integrations[0].status).toBe('starting');
expect(report.status).toBe('partial');
});
it('reports a failed startup as an error carrying the reason', () => {
const report = buildChannelStatusReport(
[slack],
PUBLISHED,
[erroredRow(slack, 'main-a', 'Credential cred-slack not found')],
isLive,
);
expect(report.integrations[0]).toEqual({
type: 'slack',
credentialId: 'cred-slack',
status: 'error',
errorMessage: 'Credential cred-slack not found',
});
expect(report.status).toBe('error');
});
it('reports connected only when the channel actually started', () => {
const report = buildChannelStatusReport([slack], PUBLISHED, [row(slack, 'main-a')], isLive);
expect(report.integrations[0].status).toBe('connected');
expect(report.status).toBe('connected');
});
it('carries settings through', () => {
const report = buildChannelStatusReport(
[telegram],
PUBLISHED,
[row(telegram, 'main-a')],
isLive,
);
expect(report.integrations[0]).toMatchObject({
type: 'telegram',
settings: { accessMode: 'public', allowedUsers: [] },
});
});
it('leaves draft entries out — they are not a channel yet', () => {
const draft: AgentIntegrationConfig = { type: 'discord', credentialId: '' };
const report = buildChannelStatusReport(
[slack, draft],
PUBLISHED,
[row(slack, 'main-a')],
isLive,
);
expect(report.integrations).toHaveLength(1);
expect(report.status).toBe('connected');
});
describe('combining what several instances observed', () => {
it('reports connected when every instance running it agrees', () => {
const report = buildChannelStatusReport(
[slack],
PUBLISHED,
[row(slack, 'main-a'), row(slack, 'main-b'), row(slack, 'main-c')],
isLive,
);
expect(report.integrations[0].status).toBe('connected');
});
it('reports an error when any instance cannot run it, because it cannot serve it either', () => {
const report = buildChannelStatusReport(
[slack],
PUBLISHED,
[
row(slack, 'main-a'),
erroredRow(slack, 'main-b', 'connect ECONNREFUSED'),
row(slack, 'main-c'),
],
isLive,
);
expect(report.integrations[0]).toMatchObject({
status: 'error',
errorMessage: 'connect ECONNREFUSED',
});
});
it('gives the same answer whatever order the rows arrive in', () => {
// The single shared row this replaced reported whichever main wrote last,
// so the same cluster state could read differently on each request.
const rows = [
row(slack, 'main-a'),
erroredRow(slack, 'main-b', 'boom'),
row(slack, 'main-c'),
];
const forward = buildChannelStatusReport([slack], PUBLISHED, rows, isLive);
const reversed = buildChannelStatusReport([slack], PUBLISHED, [...rows].reverse(), isLive);
expect(reversed).toEqual(forward);
});
it('quotes the most recent failure when several instances are failing', () => {
const older = erroredRow(slack, 'main-a', 'first cause', {
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
const newer = erroredRow(slack, 'main-b', 'current cause', {
updatedAt: new Date('2026-01-01T00:05:00.000Z'),
});
const report = buildChannelStatusReport([slack], PUBLISHED, [older, newer], isLive);
expect(report.integrations[0].errorMessage).toBe('current cause');
});
it('picks the same failure when two instances failed in the same instant', () => {
// Equal timestamps would otherwise leave the answer to row order, and the
// message could change between two identical requests.
const at = new Date('2026-01-01T00:00:00.000Z');
const rows = [
erroredRow(slack, 'main-b', 'from b', { updatedAt: at }),
erroredRow(slack, 'main-a', 'from a', { updatedAt: at }),
];
const forward = buildChannelStatusReport([slack], PUBLISHED, rows, isLive);
const reversed = buildChannelStatusReport([slack], PUBLISHED, [...rows].reverse(), isLive);
expect(forward.integrations[0].errorMessage).toBe('from a');
expect(reversed).toEqual(forward);
});
it('keeps one instances failure from bleeding onto another channel', () => {
const report = buildChannelStatusReport(
[slack, telegram],
PUBLISHED,
[erroredRow(slack, 'main-a', 'boom'), row(telegram, 'main-a')],
isLive,
);
expect(report.integrations.map((entry) => entry.status)).toEqual(['error', 'connected']);
});
});
describe('rows whose owner is gone', () => {
const expired = { expiresAt: new Date(Date.now() - 60_000) };
it('ignores an expired failure rather than pinning the channel to it', () => {
const report = buildChannelStatusReport(
[slack],
PUBLISHED,
[row(slack, 'main-a'), erroredRow(slack, 'main-dead', 'boom', expired)],
isLive,
);
expect(report.integrations[0].status).toBe('connected');
});
it('falls back to starting when every row has expired', () => {
const report = buildChannelStatusReport(
[slack],
PUBLISHED,
[row(slack, 'main-dead', expired)],
isLive,
);
expect(report.integrations[0].status).toBe('starting');
});
it('trusts a row with no expiry, which means nothing is refreshing it by design', () => {
const report = buildChannelStatusReport(
[slack],
PUBLISHED,
[row(slack, 'main-a', { expiresAt: null })],
isLive,
);
expect(report.integrations[0].status).toBe('connected');
});
});
describe('rollup', () => {
it('is partial when one channel runs and another does not', () => {
const report = buildChannelStatusReport(
[slack, telegram],
PUBLISHED,
[row(slack, 'main-a'), erroredRow(telegram, 'main-a', 'boom')],
isLive,
);
expect(report.status).toBe('partial');
expect(report.integrations.map((entry) => entry.status)).toEqual(['connected', 'error']);
});
it('is error only when nothing is running', () => {
const report = buildChannelStatusReport(
[slack, telegram],
PUBLISHED,
[erroredRow(slack, 'main-a', 'boom'), erroredRow(telegram, 'main-a', 'boom')],
isLive,
);
expect(report.status).toBe('error');
});
it('does not claim an error while channels are still starting', () => {
const report = buildChannelStatusReport([slack, telegram], PUBLISHED, [], isLive);
expect(report.status).toBe('partial');
});
});
});
@@ -14,6 +14,7 @@ import type { UrlService } from '@/services/url.service';
import { AgentExecutionOrchestratorService } from '../../agent-execution-orchestrator.service';
import type { Agent } from '../../entities/agent.entity';
import type { AgentChannelStatusReporter } from '../agent-channel-status-reporter';
import type { AgentRepository } from '../../repositories/agent.repository';
import { AgentChatBridge } from '../agent-chat-bridge';
import {
@@ -126,6 +127,7 @@ function buildServiceWith(
publisher?: ReturnType<typeof mock<Publisher>>;
urlService?: ReturnType<typeof mock<UrlService>>;
chatSubscriptionStateService?: ReturnType<typeof mock<AgentChatSubscriptionStateService>>;
statusReporter?: ReturnType<typeof mock<AgentChannelStatusReporter>>;
leaderChannelRelay?: ReturnType<typeof mock<LeaderChannelRelayService>>;
} = {},
) {
@@ -136,6 +138,7 @@ function buildServiceWith(
const urlService = opts.urlService ?? mock<UrlService>();
const chatSubscriptionStateService =
opts.chatSubscriptionStateService ?? mock<AgentChatSubscriptionStateService>();
const statusReporter = opts.statusReporter ?? mock<AgentChannelStatusReporter>();
const leaderChannelRelay = opts.leaderChannelRelay ?? mock<LeaderChannelRelayService>();
const logger = mockLogger();
const instanceSettings = mock<InstanceSettings>({ isLeader: opts.isLeader ?? true });
@@ -153,6 +156,7 @@ function buildServiceWith(
publisher,
globalConfig,
chatSubscriptionStateService,
statusReporter,
leaderChannelRelay,
);
@@ -164,6 +168,7 @@ function buildServiceWith(
publisher,
urlService,
chatSubscriptionStateService,
statusReporter,
leaderChannelRelay,
instanceSettings,
logger,
@@ -275,6 +280,7 @@ describe('ChatIntegrationService', () => {
mock(),
mock<GlobalConfig>({ multiMainSetup: { enabled: false } } as Partial<GlobalConfig>),
mock<AgentChatSubscriptionStateService>(),
mock<AgentChannelStatusReporter>(),
mock<LeaderChannelRelayService>(),
);
@@ -914,103 +920,56 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
Container.reset();
});
describe('reconnectAll', () => {
it('skips integrations that require the leader when this main is a follower', async () => {
describe('startChannel', () => {
it('runs external hooks on the leader, because cluster-wide setup happens once', async () => {
const registry = new ChatIntegrationRegistry();
registry.register(new FakeIntegration('telegram', true));
registry.register(new FakeIntegration('linear', false));
const agentRepository = mock<AgentRepository>();
agentRepository.findPublished.mockResolvedValue([
makeAgent({
integrations: [
{ type: 'telegram', credentialId: 'c1' },
{ type: 'linear', credentialId: 'c2' },
],
}),
]);
const { service } = buildServiceWith({
isLeader: false,
registry,
agentRepository,
});
const { service } = buildServiceWith({ isLeader: true, registry });
const connectSpy = vi.spyOn(service, 'connect').mockResolvedValue();
await service.reconnectAll();
await service.startChannel(makeAgent(), { type: 'linear', credentialId: 'c1' });
expect(connectSpy).toHaveBeenCalledTimes(1);
// Followers must not run external hooks during startup reconnect. The
// leader owns external setup; followers only build local runtime state.
expect(connectSpy).toHaveBeenCalledWith(
'agent-1',
{ type: 'linear', credentialId: 'c2' },
{ type: 'linear', credentialId: 'c1' },
'project-1',
{ skipExternalHooks: false },
);
});
it('skips external hooks on a follower, which only builds local runtime state', async () => {
const registry = new ChatIntegrationRegistry();
registry.register(new FakeIntegration('linear', false));
const { service } = buildServiceWith({ isLeader: false, registry });
const connectSpy = vi.spyOn(service, 'connect').mockResolvedValue();
await service.startChannel(makeAgent(), { type: 'linear', credentialId: 'c1' });
expect(connectSpy).toHaveBeenCalledWith(
'agent-1',
{ type: 'linear', credentialId: 'c1' },
'project-1',
{ skipExternalHooks: true },
);
});
});
it('connects every integration when this main is the leader and runs external hooks', async () => {
const registry = new ChatIntegrationRegistry();
registry.register(new FakeIntegration('telegram', true));
registry.register(new FakeIntegration('linear', false));
const agentRepository = mock<AgentRepository>();
agentRepository.findPublished.mockResolvedValue([
makeAgent({
integrations: [
{ type: 'telegram', credentialId: 'c1' },
{ type: 'linear', credentialId: 'c2' },
],
}),
]);
const { service } = buildServiceWith({
isLeader: true,
registry,
agentRepository,
});
const connectSpy = vi.spyOn(service, 'connect').mockResolvedValue();
await service.reconnectAll();
expect(connectSpy).toHaveBeenCalledTimes(2);
for (const call of connectSpy.mock.calls) {
expect(call[3]).toEqual({ skipExternalHooks: false });
}
});
it('skips integrations that are already connected', async () => {
describe('listLiveChannels / hasLiveChannel', () => {
it('reports the channels this main is running', async () => {
const registry = new ChatIntegrationRegistry();
registry.register(new FakeIntegration('linear', false));
const agentRepository = mock<AgentRepository>();
agentRepository.findPublished.mockResolvedValue([
makeAgent({
integrations: [{ type: 'linear', credentialId: 'c1' }],
}),
]);
const { service } = buildServiceWith({
isLeader: true,
registry,
agentRepository,
});
// Pretend this integration is already connected (e.g. leader-takeover
// scenario where webhook integrations were already running on the
// former-follower).
const { service } = buildServiceWith({ registry });
const ref = { agentId: 'agent-1', integrationType: 'linear', credentialId: 'c1' };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const internal = service as any;
internal.connections.set('agent-1:linear:c1', {});
internal.connections.set('agent-1:linear:c1', { ref });
const connectSpy = vi.spyOn(service, 'connect').mockResolvedValue();
await service.reconnectAll();
expect(connectSpy).not.toHaveBeenCalled();
expect(service.listLiveChannels()).toEqual([ref]);
expect(service.hasLiveChannel(ref)).toBe(true);
expect(service.hasLiveChannel({ ...ref, credentialId: 'c2' })).toBe(false);
});
});
@@ -1756,3 +1715,248 @@ describe('ChatIntegrationService — multi-main role-aware behavior', () => {
});
});
});
describe('ChatIntegrationService — channel status recording', () => {
const slackRef = {
agentId: 'agent-1',
integrationType: 'slack',
credentialId: 'cred-1',
};
function buildForConnect(opts: { chatConstructionFails?: boolean } = {}) {
const integration = new FakeIntegration('slack', false);
(integration as unknown as { createAdapter: () => Promise<unknown> }).createAdapter =
async () => ({ name: 'slack' });
const registry = new ChatIntegrationRegistry();
registry.register(integration);
const credentialsService = mock<CredentialsService>();
mockProjectCredential(credentialsService, { id: 'cred-1' } as CredentialsEntity);
const urlService = mock<UrlService>();
urlService.getWebhookBaseUrl.mockReturnValue('https://n8n.test/');
const state = mock<StateAdapter>();
state.disconnect.mockResolvedValue(undefined);
const chatSubscriptionStateService = mock<AgentChatSubscriptionStateService>();
chatSubscriptionStateService.createStateAdapter.mockReturnValue(state);
// An ingress connect builds a bridge, which pulls half the module out of the
// container; none of it is what these tests are about.
vi.spyOn(AgentChatBridge, 'create').mockReturnValue(mock<AgentChatBridge>());
vi.spyOn(esmLoader, 'loadMemoryState').mockResolvedValue({
createMemoryState: vi.fn(() => mock<StateAdapter>()),
} as never);
vi.spyOn(esmLoader, 'loadChatSdk').mockResolvedValue({
Chat: vi.fn(function ChatMock() {
if (opts.chatConstructionFails) throw new Error('chat construction failed');
return {
initialize: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
webhooks: { slack: vi.fn() },
onNewMention: vi.fn(),
onSubscribedMessage: vi.fn(),
onAction: vi.fn(),
getAdapter: vi.fn(),
openDM: vi.fn(),
thread: vi.fn(),
channel: vi.fn(),
getUser: vi.fn(),
};
}),
} as never);
return buildServiceWith({
registry,
credentialsService,
urlService,
chatSubscriptionStateService,
});
}
beforeEach(() => {
Container.reset();
Container.set(AgentExecutionOrchestratorService, mock<AgentExecutionOrchestratorService>());
});
afterEach(() => {
vi.restoreAllMocks();
});
it('records a channel that started', async () => {
const { service, statusReporter } = buildForConnect();
await service.connect('agent-1', slackIntegration, 'project-1');
expect(statusReporter.recordConnected).toHaveBeenCalledWith(slackRef);
expect(statusReporter.recordFailure).not.toHaveBeenCalled();
});
it('records why a channel failed to start, and still reports the failure to the caller', async () => {
const { service, statusReporter } = buildForConnect({ chatConstructionFails: true });
await expect(service.connect('agent-1', slackIntegration, 'project-1')).rejects.toThrow(
'chat construction failed',
);
expect(statusReporter.recordFailure).toHaveBeenCalledWith(slackRef, expect.any(Error));
});
it('records nothing for an outbound Preview connection, which is not a channel', async () => {
const { service, statusReporter } = buildForConnect();
await service.connect('agent-1', slackIntegration, 'project-1', { ingressEnabled: false });
expect(statusReporter.recordConnected).not.toHaveBeenCalled();
expect(statusReporter.recordFailure).not.toHaveBeenCalled();
});
it('withdraws its own account whenever it stops running the channel', async () => {
// Every teardown means the same thing for this instance's row, so they all
// converge here: removal, unpublish, leader stepdown, a peer applying a
// broadcast, and the reconciler releasing a ghost.
const { service, statusReporter } = buildForConnect();
await service.connect('agent-1', slackIntegration, 'project-1');
await service.disconnect('agent-1', slackIntegration, { skipExternalHooks: true });
expect(statusReporter.withdraw).toHaveBeenCalledWith(slackRef);
});
it('withdraws its own account when the channel is removed outright', async () => {
const { service, statusReporter } = buildForConnect();
await service.connect('agent-1', slackIntegration, 'project-1');
await service.disconnectChannel('agent-1', slackIntegration);
expect(statusReporter.withdraw).toHaveBeenCalledWith(slackRef);
});
it('has nothing to withdraw when it was not running the channel', async () => {
const { service, statusReporter } = buildServiceWith();
await service.disconnect('agent-1', slackIntegration);
expect(statusReporter.withdraw).not.toHaveBeenCalled();
});
});
describe('ChatIntegrationService — reporting failures from every startup step', () => {
const slackRef = { agentId: 'agent-1', integrationType: 'slack', credentialId: 'cred-1' };
beforeEach(() => {
Container.reset();
});
it('records a credential that cannot be decrypted, which happens before the adapter is built', async () => {
const registry = new ChatIntegrationRegistry();
registry.register(new FakeIntegration('slack', false));
// No project credential configured, so `decryptCredentialForProject` throws.
const credentialsService = mock<CredentialsService>();
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([]);
credentialsService.findAllGlobalCredentialIds.mockResolvedValue([]);
const { service, statusReporter } = buildServiceWith({ registry, credentialsService });
await expect(service.connect('agent-1', slackIntegration, 'project-1')).rejects.toThrow(
/not found or not accessible/,
);
expect(statusReporter.recordFailure).toHaveBeenCalledWith(slackRef, expect.any(Error));
});
it('records a pre-connect rejection, so a claimed credential is reported rather than only thrown', async () => {
const integration = new FakeIntegration('slack', false);
(integration as unknown as { onBeforeConnect: () => Promise<void> }).onBeforeConnect =
async () => {
throw new Error('already connected to agent "Other"');
};
const registry = new ChatIntegrationRegistry();
registry.register(integration);
const credentialsService = mock<CredentialsService>();
mockProjectCredential(credentialsService, { id: 'cred-1' } as CredentialsEntity);
const { service, statusReporter } = buildServiceWith({ registry, credentialsService });
await expect(service.connect('agent-1', slackIntegration, 'project-1')).rejects.toThrow(
'already connected to agent "Other"',
);
expect(statusReporter.recordFailure).toHaveBeenCalledWith(slackRef, expect.any(Error));
});
it('does not report an outbound Preview connection that fails, since it is not a channel', async () => {
const registry = new ChatIntegrationRegistry();
registry.register(new FakeIntegration('slack', false));
const credentialsService = mock<CredentialsService>();
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([]);
credentialsService.findAllGlobalCredentialIds.mockResolvedValue([]);
const { service, statusReporter } = buildServiceWith({ registry, credentialsService });
await expect(
service.connect('agent-1', slackIntegration, 'project-1', { ingressEnabled: false }),
).rejects.toThrow();
expect(statusReporter.recordFailure).not.toHaveBeenCalled();
});
});
describe('ChatIntegrationService.assertStartupPreconditions', () => {
beforeEach(() => {
Container.reset();
});
it('does not re-run the platform config check, so a legacy entry can still publish', async () => {
// Publishing already validates the whole configuration. Re-running a
// platform's own check here would newly reject an agent whose persisted
// channel predates a later-added required setting — a Telegram entry with no
// `settings` could no longer be republished.
const integration = new FakeIntegration('telegram', false);
const validateConfig = vi.fn(() => {
throw new Error('Telegram integration settings are required');
});
(integration as unknown as { validateConfig: typeof validateConfig }).validateConfig =
validateConfig;
const registry = new ChatIntegrationRegistry();
registry.register(integration);
const { service } = buildServiceWith({ registry });
await expect(
service.assertStartupPreconditions(
'agent-1',
{ type: 'telegram', credentialId: 'c1' },
'project-1',
),
).resolves.toBeUndefined();
expect(validateConfig).not.toHaveBeenCalled();
});
it('runs the platforms own deterministic claim check', async () => {
const integration = new FakeIntegration('telegram', false);
const assertStartupPreconditions = vi.fn().mockResolvedValue(undefined);
(
integration as unknown as { assertStartupPreconditions: typeof assertStartupPreconditions }
).assertStartupPreconditions = assertStartupPreconditions;
const registry = new ChatIntegrationRegistry();
registry.register(integration);
const { service } = buildServiceWith({ registry });
await service.assertStartupPreconditions(
'agent-1',
{ type: 'telegram', credentialId: 'c1' },
'project-1',
);
// No decrypted credential: the check reads our own state only, which is what
// makes it safe to run before a publish.
expect(assertStartupPreconditions).toHaveBeenCalledWith({
agentId: 'agent-1',
projectId: 'project-1',
credentialId: 'c1',
});
});
});
@@ -0,0 +1,432 @@
import { isDraftIntegration, type AgentIntegrationConfig } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { AgentsConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import { OnLeaderTakeover, OnShutdown } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
import { ErrorReporter, InstanceSettings } from 'n8n-core';
import { AgentChannelStatusReporter } from './agent-channel-status-reporter';
import { ChatIntegrationRegistry } from './agent-chat-integration';
import { ChatIntegrationService } from './chat-integration.service';
import type { Agent } from '../entities/agent.entity';
import type { AgentChannelStatus } from '../entities/agent-channel-status.entity';
import {
AgentChannelStatusRepository,
type AgentChannelRef,
} from '../repositories/agent-channel-status.repository';
import { AgentRepository } from '../repositories/agent.repository';
/** Why a pass is running. Only the periodic one waits out a channel's backoff. */
export type ChannelReconcileReason = 'startup' | 'leader-takeover' | 'interval';
/** Channels a pass should see running, keyed by {@link channelKey}. */
type WantedChannels = Map<string, { agent: Agent; integration: AgentIntegrationConfig }>;
/**
* How long shutdown waits for a pass to finish before withdrawing anyway. Well
* inside a normal graceful-shutdown budget: the point is to let a pass that is
* nearly done finish, not to see a stalled one through.
*/
const SHUTDOWN_SETTLE_MS = 5 * Time.seconds.toMilliseconds;
function channelKey(ref: AgentChannelRef): string {
return `${ref.agentId}:${ref.integrationType}:${ref.credentialId}`;
}
/**
* Keeps the channels this main runs in line with the channels the published
* configuration asks for, and keeps its account of them current.
*
* Before this loop, starting a channel happened exactly once — on publish, or in
* a single pass at startup — and every one of those paths swallowed its errors.
* A channel that failed to start therefore stayed down until someone republished
* the agent, while the API still reported it as connected. Causes are not
* enumerated here on purpose: a known one is a bug to fix where it happens, and
* this loop is what makes the unknown ones recoverable.
*
* The pass ticks on every main for the whole process lifetime, and what it does
* depends on the role held at that moment, so leadership changes never start or
* stop it. Two sets drive one pass:
*
* - what this main should run (every published agent's channels, minus
* leader-only ones when this main is a follower),
* - what this main is running.
*
* Every write it makes is to this main's own status rows. Rows belonging to
* other processes are only ever deleted once their owner has stopped refreshing
* them, which is the one thing their owner cannot do for itself.
*/
@Service()
export class AgentChannelReconciler {
private reconcileInterval: NodeJS.Timeout | undefined;
/**
* Whether {@link init} ran. Kept apart from `reconcileInterval`, which says
* only whether the repeating pass is scheduled — with a zero interval there is
* no timer, and a takeover still has to be served.
*/
private isInitialized = false;
private isShuttingDown = false;
/**
* The pass currently running, if any. Two passes at once would both see a
* channel as not running and both start it, and the second would tear down
* what the first had just built — including running a platform's external
* setup twice. A pass slower than the interval therefore skips ticks rather
* than overlapping them, and shutdown waits for it.
*/
private inFlight: Promise<void> | undefined;
constructor(
private readonly logger: Logger,
private readonly agentsConfig: AgentsConfig,
private readonly agentRepository: AgentRepository,
private readonly channelStatusRepository: AgentChannelStatusRepository,
private readonly statusReporter: AgentChannelStatusReporter,
private readonly chatIntegrationService: ChatIntegrationService,
private readonly integrationRegistry: ChatIntegrationRegistry,
private readonly instanceSettings: InstanceSettings,
private readonly errorReporter: ErrorReporter,
) {}
/**
* Start the loop and run the first pass now rather than an interval later, so
* this main picks up its channels as soon as it boots.
*
* A zero interval turns off the repeating pass, not the boot pass: this is the
* only thing that starts a published agent's channels on this main, so
* skipping it entirely would leave the instance with no channels at all rather
* than with no retries. Same for a leader takeover, which is when the
* leader-only channels become this main's to run.
*/
init(): void {
const intervalSeconds = this.agentsConfig.channelReconcileIntervalSeconds;
if (intervalSeconds <= 0) {
this.logger.info(
'[AgentChannelReconciler] Periodic channel reconciliation is disabled — channels still start on boot and on leader takeover, and one that fails to start is retried on either, but nothing will retry it in between except republishing the agent',
);
} else {
this.reconcileInterval = setInterval(
async () => await this.reconcile('interval'),
intervalSeconds * Time.seconds.toMilliseconds,
);
// Never a reason to hold the process open.
this.reconcileInterval.unref();
this.logger.debug(`[AgentChannelReconciler] Reconciling channels every ${intervalSeconds}s`);
}
this.isInitialized = true;
void this.reconcile('startup');
}
/**
* Withdraw this process's rows on the way out, so a rolling restart doesn't
* leave its channels looking degraded for the length of a lease. A crash skips
* this, which is what the lease is for.
*/
@OnShutdown()
async shutdown(): Promise<void> {
this.isShuttingDown = true;
clearInterval(this.reconcileInterval);
this.reconcileInterval = undefined;
// Wait out a pass already running, so a channel it started is reported as
// running for the moment it still is. Bounded, because a startup can stall
// on a platform that never answers and a deployment must not wait on it:
// past the bound the rows are withdrawn anyway, and the withdrawal seals
// the reporter, so a startup that resolves afterwards cannot report against
// a host that is gone.
await this.settleInFlight();
await this.statusReporter.withdrawAll();
}
private async settleInFlight(): Promise<void> {
const pass = this.inFlight;
if (!pass) return;
let timer: NodeJS.Timeout | undefined;
const bound = new Promise<void>((resolve) => {
timer = setTimeout(resolve, SHUTDOWN_SETTLE_MS);
timer.unref();
});
try {
await Promise.race([pass.catch(() => {}), bound]);
} finally {
clearTimeout(timer);
}
}
/**
* A fresh leader owns channels the previous one did — polling channels above
* all — and must not wait an interval to claim them. Gated on `init` having
* run, so an instance type that never starts channels (a worker) stays inert.
*/
@OnLeaderTakeover()
async reconcileOnLeaderTakeover(): Promise<void> {
if (!this.isInitialized) return;
await this.reconcile('leader-takeover');
}
/**
* One pass. Errors never escape: a pass that throws would kill the interval
* and take recovery with it, so the next tick retries instead.
*/
async reconcile(reason: ChannelReconcileReason): Promise<void> {
if (this.isShuttingDown) return;
// An interval tick that lands mid-pass is dropped: the next one is a whole
// interval away and re-reads everything anyway. Startup and takeover are not
// droppable — they carry a role change the running pass decided before it, so
// they queue behind it and then run for themselves. Only waiting would leave
// a promoted main's leader-only channels stopped until the next tick.
if (this.inFlight && reason === 'interval') return await this.inFlight.catch(() => {});
const pass = (this.inFlight ?? Promise.resolve())
.catch(() => {})
.then(async () => {
if (this.isShuttingDown) return;
await this.runPass(reason);
});
this.inFlight = pass;
try {
await pass;
} finally {
if (this.inFlight === pass) this.inFlight = undefined;
}
}
private async runPass(reason: ChannelReconcileReason): Promise<void> {
try {
const agents = await this.agentRepository.findPublished();
const ownStatuses = await this.channelStatusRepository.findOwnAll();
const wantedHere: WantedChannels = new Map();
for (const agent of agents) {
for (const integration of agent.integrations ?? []) {
// A draft entry has no credential to connect with. The builder writes
// it so the panel can show a needs-setup chip, and publishing rejects
// it, so it can only be here mid-setup.
if (isDraftIntegration(integration)) continue;
const key = channelKey({
agentId: agent.id,
integrationType: integration.type,
credentialId: integration.credentialId,
});
if (this.runsHere(integration)) wantedHere.set(key, { agent, integration });
}
}
await this.settleWanted(wantedHere, ownStatuses, reason);
await this.releaseGhosts(wantedHere);
await this.forgetOwnOrphans(wantedHere, ownStatuses);
await this.sweepExpired();
} catch (error) {
this.errorReporter.error(error, { shouldBeLogged: true });
}
}
/**
* Leader-only channels (Telegram polling) run on exactly one main, so a
* follower must leave them alone — including their status, which belongs to
* whichever main is actually running them.
*/
private runsHere(integration: AgentIntegrationConfig): boolean {
const definition = this.integrationRegistry.get(integration.type);
return !definition?.requiresLeader() || this.instanceSettings.isLeader;
}
/**
* Bring every channel this main should run to a running state, and keep this
* main's account of the ones already running from going stale.
*/
private async settleWanted(
wantedHere: WantedChannels,
ownStatuses: AgentChannelStatus[],
reason: ChannelReconcileReason,
): Promise<void> {
const ownByChannel = new Map(ownStatuses.map((status) => [channelKey(status), status]));
const now = new Date();
for (const [key, { agent, integration }] of wantedHere) {
if (this.isShuttingDown) return;
const ref = this.refOf(agent, integration);
const own = ownByChannel.get(key);
// `wantedHere` was decided when the pass began, and a stepdown since then
// makes a leader-only channel someone else's. Checked before the branches
// below so a demoted main neither starts it — putting a polling loop on a
// follower, the one thing the role gate exists to prevent — nor goes on
// affirming a row for it. The row is withdrawn here because
// `forgetOwnOrphans` reads the same stale snapshot and would leave it
// standing, reported as this instance's, until the next pass.
if (!this.runsHere(integration)) {
await this.statusReporter.withdraw(ref);
continue;
}
if (this.chatIntegrationService.hasLiveChannel(ref)) {
await this.affirmRunning(ref, own);
continue;
}
// Startup and takeover always try: the backoff was set by an earlier life
// of this process or by whatever it inherited, and a restart or a
// promotion is exactly when the cause may have gone away.
if (reason === 'interval' && !this.statusReporter.isRetryReady(own, now)) {
// Waiting is still this main standing behind what it said, so the lease
// is kept alive. From the third consecutive failure on the backoff
// outgrows a lease (four intervals against three), and letting the row
// expire mid-wait would have the sweep delete it: the channel would
// report as `starting` with no reason given, and the next pass — seeing
// no row — would retry at once and count from one again, so the backoff
// could never grow past that point.
await this.statusReporter.refreshLease(ref);
continue;
}
try {
await this.chatIntegrationService.startChannel(agent, integration);
this.logger.info('[AgentChannelReconciler] Started channel', {
agentId: agent.id,
type: integration.type,
reason,
});
} catch (error) {
// `connect` has already recorded why, which is what the user sees.
// Logged at warn rather than error because a retry is scheduled and the
// state is reported — this is not the last word on the channel.
this.logger.warn('[AgentChannelReconciler] Could not start channel', {
agentId: agent.id,
type: integration.type,
attempts: (own?.attempts ?? 0) + 1,
// Scrubbed for the same reason `recordFailure` scrubs it: a platform
// error can quote the credential it failed with, and a Telegram API
// URL carries the bot token in its path.
error: scrubSecretsInText(error instanceof Error ? error.message : String(error)),
});
}
}
}
/**
* Keep this main's row saying what is true of this main: the channel is up.
*
* A row that already says so only needs its lease extended. A missing one has
* to be written — that is every channel on an instance that just upgraded,
* live but never reported, which would otherwise read as `starting` forever.
* One saying `error` belongs to an earlier attempt by this same process that
* has since succeeded.
*/
private async affirmRunning(
ref: AgentChannelRef,
own: AgentChannelStatus | undefined,
): Promise<void> {
if (own?.status === 'connected') {
await this.statusReporter.refreshLease(ref);
return;
}
await this.statusReporter.recordConnected(ref);
}
/**
* Release channels this main runs but should not — the agent was unpublished,
* the channel removed, or this main is a follower holding a leader-only
* channel. Teardown withdraws this main's row on its own, because in every one
* of those cases this main has stopped running the channel.
*
* Released locally, never through the cluster-wide path: a demoted main
* releasing a polling channel would otherwise ask the main that just took it
* over to stop running it, and run the platform-side teardown — deregistering
* the webhook the new owner needs — on the way.
*/
private async releaseGhosts(wantedHere: WantedChannels): Promise<void> {
for (const ref of this.chatIntegrationService.listLiveChannels()) {
if (this.isShuttingDown) return;
if (wantedHere.has(channelKey(ref))) continue;
try {
await this.chatIntegrationService.releaseChannelLocally(ref.agentId, {
type: ref.integrationType,
credentialId: ref.credentialId,
});
this.logger.info('[AgentChannelReconciler] Released channel', {
agentId: ref.agentId,
type: ref.integrationType,
});
} catch (error) {
this.logger.warn('[AgentChannelReconciler] Could not release channel', {
agentId: ref.agentId,
type: ref.integrationType,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
/**
* Drop this main's rows for channels it should no longer be reporting on, when
* there is no live connection left to withdraw them — a failed startup that was
* then unpublished, or a channel that moved to the leader. Left alone they
* would report an error against a channel nobody is running any more.
*/
private async forgetOwnOrphans(
wantedHere: WantedChannels,
ownStatuses: AgentChannelStatus[],
): Promise<void> {
for (const status of ownStatuses) {
if (this.isShuttingDown) return;
if (wantedHere.has(channelKey(status))) continue;
await this.statusReporter.withdraw({
agentId: status.agentId,
integrationType: status.integrationType,
credentialId: status.credentialId,
});
}
}
/**
* Delete rows whose owner stopped refreshing them. This is the only place a
* process touches rows it does not own, and it is safe because a lease past its
* expiry means the owner is gone: it crashed, and `hostId` is regenerated on
* restart, so it will never recognise them as its own again.
*
* Leader-only because one main is enough, and because it is a cluster-wide
* cleanup rather than anybody's own account.
*/
private async sweepExpired(): Promise<void> {
if (!this.instanceSettings.isLeader) return;
try {
const deleted = await this.channelStatusRepository.deleteExpired(new Date());
if (deleted > 0) {
this.logger.debug(
`[AgentChannelReconciler] Cleared ${deleted} channel status rows left by instances that are gone`,
);
}
} catch (error) {
this.logger.warn('[AgentChannelReconciler] Could not sweep expired channel statuses', {
error: error instanceof Error ? error.message : String(error),
});
}
}
private refOf(agent: Agent, integration: AgentIntegrationConfig): AgentChannelRef {
return {
agentId: agent.id,
integrationType: integration.type,
credentialId: integration.credentialId,
};
}
}
@@ -0,0 +1,188 @@
import { Logger } from '@n8n/backend-common';
import { AgentsConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import { Service } from '@n8n/di';
import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
import type { AgentChannelStatus } from '../entities/agent-channel-status.entity';
import {
AgentChannelStatusRepository,
type AgentChannelRef,
} from '../repositories/agent-channel-status.repository';
/**
* How long a row counts for, as a multiple of the reconcile interval. Three
* gives the owner a missed pass and some jitter before anyone concludes it is
* gone, while still clearing a crashed process within minutes.
*/
const LEASE_INTERVALS = 3;
/**
* Longest a failing channel waits between retries. Long enough that a channel
* failing for good stops being noise in the logs, short enough that a user who
* fixes the cause doesn't wonder whether anything is still trying.
*/
const MAX_BACKOFF_MS = 10 * Time.minutes.toMilliseconds;
/**
* This process's account of the channels it runs: what it observed, when it
* should try a failed one again, and how long its account stands.
*
* All of it is bookkeeping about reporting, so none of it may fail the operation
* being reported on — every startup path funnels through `connect`, several of
* them already swallow their own errors, and reporting must not become a new way
* for them to break. A write lost here is repaired by the next reconciliation
* pass.
*/
@Service()
export class AgentChannelStatusReporter {
/**
* Set once this process has withdrawn everything on the way out. Work that
* outlives the withdrawal still calls in here — a startup the reconciler
* stopped waiting for finishes and reports what it found — and would write a
* fresh row for a host that is already gone, leaving the channel reported
* against this instance until its lease expires. Nothing this process says
* after it has withdrawn still applies, so nothing is written.
*/
private hasWithdrawn = false;
constructor(
private readonly logger: Logger,
private readonly agentsConfig: AgentsConfig,
private readonly repository: AgentChannelStatusRepository,
) {}
/** This process has the channel running. */
async recordConnected(ref: AgentChannelRef): Promise<void> {
await this.swallow('record a running channel', ref, async () => {
await this.repository.saveOwn(ref, {
status: 'connected',
errorMessage: null,
attempts: 0,
backoffUntil: null,
expiresAt: this.leaseExpiresAt(),
});
});
}
/**
* This process could not start the channel. Counts the attempt and sets the
* deadline for the next one — read-then-write is safe because only this
* process writes this row.
*/
async recordFailure(ref: AgentChannelRef, cause: unknown): Promise<void> {
await this.swallow('record a failed channel startup', ref, async () => {
const existing = await this.repository.findOwnChannel(ref);
const attempts = (existing?.status === 'error' ? existing.attempts : 0) + 1;
await this.repository.saveOwn(ref, {
status: 'error',
// A platform or adapter error can carry the credential it failed with —
// a failed Telegram request quotes the API URL, and the bot token is in
// that path. This message is persisted and served to the UI, so it is
// scrubbed the same way recorded execution errors are.
errorMessage: scrubSecretsInText(cause instanceof Error ? cause.message : String(cause)),
attempts,
backoffUntil: this.backoffUntil(attempts),
expiresAt: this.leaseExpiresAt(),
});
});
}
/** Keep standing behind what this process already said. */
async refreshLease(ref: AgentChannelRef): Promise<void> {
await this.swallow('refresh a channel status lease', ref, async () => {
await this.repository.refreshOwnLease(ref, this.leaseExpiresAt());
});
}
/** This process no longer runs the channel, so it has nothing to say about it. */
async withdraw(ref: AgentChannelRef): Promise<void> {
await this.swallow('withdraw a channel status', ref, async () => {
await this.repository.clearOwnChannel(ref);
});
}
/** This process is going away, so nothing it said still applies. */
async withdrawAll(): Promise<void> {
// Before the delete, not after: a write landing in between belongs to work
// that is on its way out with the process, and would outlive the withdrawal.
this.hasWithdrawn = true;
try {
await this.repository.clearOwnHost();
} catch (error) {
this.logger.warn(
`[AgentChannelStatusReporter] Could not withdraw this instance's channel statuses on shutdown: ${this.describe(error)}`,
);
}
}
/**
* Whether a row still counts. A row without an expiry is never stale: nothing
* is refreshing it because reconciliation is off, so it is the only account
* there is.
*/
isLive(row: Pick<AgentChannelStatus, 'expiresAt'>, now: Date): boolean {
return row.expiresAt === null || row.expiresAt.getTime() > now.getTime();
}
/** Whether a failed channel has waited long enough to be tried again. */
isRetryReady(row: Pick<AgentChannelStatus, 'backoffUntil'> | undefined, now: Date): boolean {
if (!row?.backoffUntil) return true;
return row.backoffUntil.getTime() <= now.getTime();
}
/**
* Null when reconciliation is off: nothing would refresh the lease, so an
* expiry would quietly retire every row and leave the API with nothing to
* report.
*/
private leaseExpiresAt(): Date | null {
const intervalMs = this.intervalMs();
if (intervalMs <= 0) return null;
return new Date(Date.now() + LEASE_INTERVALS * intervalMs);
}
/**
* Exponential from one interval, capped, so a channel failing on something
* that will not fix itself — a deleted credential — stops hammering the
* platform and the log.
*/
private backoffUntil(attempts: number): Date {
const intervalMs = this.intervalMs() || Time.minutes.toMilliseconds;
const delayMs = Math.min(intervalMs * 2 ** Math.max(attempts - 1, 0), MAX_BACKOFF_MS);
return new Date(Date.now() + delayMs);
}
private intervalMs(): number {
return this.agentsConfig.channelReconcileIntervalSeconds * Time.seconds.toMilliseconds;
}
private async swallow(
what: string,
ref: AgentChannelRef,
write: () => Promise<void>,
): Promise<void> {
if (this.hasWithdrawn) {
this.logger.debug(
`[AgentChannelStatusReporter] Not going to ${what} for ${ref.integrationType} on agent ${ref.agentId} — this instance has withdrawn`,
);
return;
}
try {
await write();
} catch (error) {
this.logger.warn(
`[AgentChannelStatusReporter] Could not ${what} for ${ref.integrationType} on agent ${ref.agentId}: ${this.describe(error)}`,
);
}
}
private describe(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
}
@@ -25,12 +25,19 @@ import type {
ReplyExpectation,
} from './integration-tools';
/** Per-connection context handed to AgentChatIntegration hooks. */
export interface AgentChatIntegrationContext {
/**
* Channel identity, without the decrypted credential. Enough for checks that
* only read our own state — see {@link AgentChatIntegration.assertStartupPreconditions}.
*/
export interface AgentChannelPreconditionContext {
agentId: string;
projectId: string;
integration: AgentIntegrationConfig;
credentialId: string;
}
/** Per-connection context handed to AgentChatIntegration hooks. */
export interface AgentChatIntegrationContext extends AgentChannelPreconditionContext {
integration: AgentIntegrationConfig;
credential: Record<string, unknown>;
/** Whether this connection may receive events from the external platform. */
ingressEnabled: boolean;
@@ -320,6 +327,19 @@ export abstract class AgentChatIntegration {
*/
onBeforeConnect?(ctx: AgentChatIntegrationContext): Promise<void>;
/**
* The deterministic part of {@link onBeforeConnect}: a check that reads only
* our own state, so it always answers the same way and never depends on the
* platform being reachable.
*
* Publishing runs this as a preflight, before it writes anything, so a
* conflict a user has to resolve fails the publish outright instead of
* leaving an agent published with a channel that never started. Anything
* that calls the platform belongs in `onBeforeConnect` only — a platform
* outage is transient, and must never block a publish.
*/
assertStartupPreconditions?(ctx: AgentChannelPreconditionContext): Promise<void>;
/** Optional hook run AFTER `chat.initialize()`. Throwing triggers cleanup. */
onAfterConnect?(ctx: AgentChatIntegrationContext): Promise<void>;
@@ -0,0 +1,113 @@
import {
isDraftIntegration,
type AgentChannelRuntimeStatus,
type AgentIntegrationConfig,
type AgentIntegrationStatusEntry,
type AgentIntegrationStatusResponse,
} from '@n8n/api-types';
import type { AgentChannelStatus } from '../entities/agent-channel-status.entity';
/** Decides whether a row still counts; see `AgentChannelStatusReporter.isLive`. */
export type IsLiveRow = (row: AgentChannelStatus) => boolean;
/**
* Turn what is configured, plus what each process observed, into what the API
* reports.
*
* The two have to be read together. Configuration alone says a channel should be
* running, which is what this endpoint used to report as `connected` — the reason
* a channel that never started still looked healthy. The rows alone can't say
* anything about a channel nobody has tried to start yet.
*
* A channel can have one row per process running it, so the rows are combined
* rather than read: **any live process reporting an error makes the channel an
* error**, because a main that could not start it cannot serve its webhooks
* either, and a user who sees "connected" would have no idea why a share of
* their messages goes nowhere. Rows are only ever written by the process they
* describe, so this is a pure function of them and cannot oscillate the way a
* single shared row did.
*
* No live rows means `starting`, not an error: it is the honest answer right
* after a publish, and after an upgrade, before any pass has reported in.
*/
export function buildChannelStatusReport(
integrations: AgentIntegrationConfig[] | null | undefined,
activeVersionId: string | null,
statuses: AgentChannelStatus[],
isLive: IsLiveRow,
): AgentIntegrationStatusResponse {
const liveByChannel = new Map<string, AgentChannelStatus[]>();
for (const row of statuses) {
if (!isLive(row)) continue;
const key = channelKey(row.integrationType, row.credentialId);
liveByChannel.set(key, [...(liveByChannel.get(key) ?? []), row]);
}
// Draft entries (`credentialId: ''`) written during the initial build so the
// panel can show a needs-setup chip aren't a real channel — leaving them out
// keeps channel-setup UIs from rendering a configured state and hiding their
// own setup form.
const entries: AgentIntegrationStatusEntry[] = (integrations ?? [])
.filter((integration) => !isDraftIntegration(integration))
.map((integration) => {
const rows = liveByChannel.get(channelKey(integration.type, integration.credentialId)) ?? [];
const status = resolveStatus(activeVersionId, rows);
const failure = status === 'error' ? mostRecentFailure(rows) : undefined;
return {
type: integration.type,
credentialId: integration.credentialId,
...('settings' in integration ? { settings: integration.settings } : {}),
status,
...(failure?.errorMessage ? { errorMessage: failure.errorMessage } : {}),
};
});
return { status: rollUp(entries), integrations: entries };
}
function channelKey(integrationType: string, credentialId: string): string {
return `${integrationType}:${credentialId}`;
}
function resolveStatus(
activeVersionId: string | null,
rows: AgentChannelStatus[],
): AgentChannelRuntimeStatus {
// An unpublished agent must not receive events, so no channel of it is meant
// to be running — whatever a row left over from before the unpublish says.
if (activeVersionId === null) return 'configured';
if (rows.length === 0) return 'starting';
if (rows.some((row) => row.status === 'error')) return 'error';
return 'connected';
}
/**
* The newest failure, so a channel failing on several mains reports the most
* current reason rather than whichever row happened to be read first. `hostId`
* breaks ties, because two instances failing in the same millisecond would
* otherwise leave the answer to database row order and the message could change
* between two identical requests.
*/
function mostRecentFailure(rows: AgentChannelStatus[]): AgentChannelStatus | undefined {
return rows
.filter((row) => row.status === 'error')
.sort(
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() || a.hostId.localeCompare(b.hostId),
)[0];
}
function rollUp(entries: AgentIntegrationStatusEntry[]): AgentIntegrationStatusResponse['status'] {
if (entries.length === 0) return 'disconnected';
const statuses = entries.map((entry) => entry.status);
if (statuses.every((status) => status === 'configured')) return 'configured';
if (statuses.every((status) => status === 'connected')) return 'connected';
// Something is running and something is not, so neither word on its own is
// true; `error` is reserved for when nothing is up.
if (statuses.some((status) => status === 'connected')) return 'partial';
return statuses.some((status) => status === 'error') ? 'error' : 'partial';
}
@@ -1,7 +1,7 @@
import { AgentIntegrationConfig, type AgentIntegrationSettings } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { OnLeaderStepdown, OnLeaderTakeover, OnPubSubEvent } from '@n8n/decorators';
import { OnLeaderStepdown, OnPubSubEvent } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import type { Channel, Chat as ChatSdk, StateAdapter, Thread, UserInfo } from 'chat';
@@ -31,7 +31,9 @@ import {
import { channelIntegrationRecorder } from './recording/channel-integration-recorder';
import { recordAdapterCalls } from './recording/recording-adapter';
import type { Agent } from '../entities/agent.entity';
import type { AgentChannelRef } from '../repositories/agent-channel-status.repository';
import { AgentRepository } from '../repositories/agent.repository';
import { AgentChannelStatusReporter } from './agent-channel-status-reporter';
// ---------------------------------------------------------------------------
// Chat SDK local interfaces
@@ -62,6 +64,11 @@ export interface ChatInstance {
interface ChatAgentConnection {
chat: ChatInstance;
bridge?: AgentChatBridge;
/**
* Which channel this connection is. The map key encodes the same thing, but
* as one string — this keeps callers that need the parts from parsing it back.
*/
ref: AgentChannelRef;
/**
* Context captured at connect time. Used by `disconnectOne` to invoke
* `onBeforeDisconnect` hooks with the same decrypted credential the connect
@@ -137,6 +144,7 @@ export class ChatIntegrationService {
private readonly publisher: Publisher,
private readonly globalConfig: GlobalConfig,
private readonly chatSubscriptionStateService: AgentChatSubscriptionStateService,
private readonly statusReporter: AgentChannelStatusReporter,
private readonly leaderChannelRelay: LeaderChannelRelayService,
) {}
@@ -176,6 +184,32 @@ export class ChatIntegrationService {
return type ? this.integrationRegistry.get(type) : undefined;
}
/**
* Run only the checks that read our own state, without connecting anything.
*
* Publishing uses this to reject a channel it cannot start — a credential
* another agent already claims — before it writes a version, so the agent is
* never left published with a channel that never came up. Deliberately not
* the full `onBeforeConnect`: that may call the platform, and a platform
* outage must not block a publish (the reconciler retries those instead).
*/
async assertStartupPreconditions(
agentId: string,
integration: AgentIntegrationConfig,
projectId: string,
): Promise<void> {
const implementation = this.integrationRegistry.require(integration.type);
// Deliberately not `validateConfig`: publishing already validates the whole
// configuration, and running it again here would newly reject an agent whose
// persisted channel predates a later-added required setting — a legacy
// Telegram entry with no `settings` could no longer be republished.
await implementation.assertStartupPreconditions?.({
agentId,
projectId,
credentialId: integration.credentialId,
});
}
async validateBeforeConnect(
agentId: string,
integration: AgentIntegrationConfig,
@@ -245,11 +279,50 @@ export class ChatIntegrationService {
}
}
/**
* Build this main's own runtime state for a channel and record what came of it.
*
* Every main that actually runs a channel arrives here — the one handling the
* user's request, a peer applying a broadcast, and the leader serving a
* follower's relayed request — and each reports on the connection it built,
* because the row is that process's account of itself. A follower that handed
* the channel to the leader reports nothing: it is not running it, and the
* leader's own row is the answer for that channel.
*/
private async connectLocal(
agentId: string,
integration: AgentIntegrationConfig,
projectId: string,
options: ConnectOptions = {},
): Promise<void> {
const ingressEnabled = options.ingressEnabled ?? true;
if (!ingressEnabled) {
// An outbound Preview connection is not a channel, so it has no status.
await this.establishConnection(agentId, integration, projectId, options);
return;
}
const ref = this.channelRef(agentId, integration);
try {
await this.establishConnection(agentId, integration, projectId, options);
} catch (error) {
// Outside `establishConnection` so that everything a startup does is
// covered — decrypting the credential and the pre-connect hook run before
// its internal cleanup block, and a failure in either is exactly the kind
// a user needs reported: an inaccessible credential, or one already
// claimed. Recorded once, here, whichever step failed.
await this.statusReporter.recordFailure(ref, error);
throw error;
}
await this.statusReporter.recordConnected(ref);
}
private async establishConnection(
agentId: string,
integration: AgentIntegrationConfig,
projectId: string,
options: ConnectOptions = {},
): Promise<void> {
const key = this.connectionKey(agentId, integration.type, integration.credentialId);
const ingressEnabled = options.ingressEnabled ?? true;
@@ -380,6 +453,7 @@ export class ChatIntegrationService {
chat: chatInstance,
bridge,
context: ctx,
ref: this.channelRef(agentId, integration),
});
// Runs on every main, never gated on `skipExternalHooks`: this builds
@@ -445,6 +519,23 @@ export class ChatIntegrationService {
await this.disconnectLocal(agentId, integration, options);
}
/**
* Stop running a channel on this main, and only on this main: no external
* teardown, and no request to the leader to stop running it either.
*
* This is what a main does when a channel is no longer its to run rather than
* gone — it stepped down and the new leader owns it now, or the channel was
* removed and whoever handled that already did the cluster-wide part. Going
* through {@link disconnect} instead would relay a teardown to the leader and
* stop a channel that is meant to keep running.
*/
async releaseChannelLocally(
agentId: string,
integration: { credentialId: string; type: string },
): Promise<void> {
await this.disconnectLocal(agentId, integration, { skipExternalHooks: true });
}
/**
* Remove a chat channel everywhere. Persisted thread subscriptions are deleted
* by default for real integration removals, but can be preserved for unpublish.
@@ -750,52 +841,40 @@ export class ChatIntegrationService {
return undefined;
}
/** The channels this main currently has running, ingress connections only. */
listLiveChannels(): AgentChannelRef[] {
return [...this.connections.values()].map((conn) => conn.ref);
}
hasLiveChannel(ref: AgentChannelRef): boolean {
return this.connections.has(
this.connectionKey(ref.agentId, ref.integrationType, ref.credentialId),
);
}
/**
* Reconnect all agents that have integrations configured. Called on startup
* (every main) and on `leader-takeover` in multi-main mode.
* Start one channel of a published agent, for whichever role this main holds.
*
* Webhook-driven integrations connect on every main so that inbound webhooks
* load-balanced across mains always find a live handler. Integrations that
* declare `requiresLeader()` (e.g. Telegram polling) only connect on the
* leader so a single instance owns the long-poll loop.
* load-balanced across mains always find a live handler; integrations that
* declare `requiresLeader()` (e.g. Telegram polling) are the caller's job to
* filter out on followers, so a single instance owns the long-poll loop.
*
* Already-connected keys are skipped so this is a safe idempotent operation
* — important for leader takeover, where a former follower already holds
* webhook integrations and only needs to add the leader-only ones.
* External setup runs once per cluster and the leader claims that role here,
* because the alternative — every main registering the same webhook — is a
* race. Followers build local runtime state only.
*
* Which channels to start, and when to retry one that failed, is
* {@link AgentChannelReconciler}'s decision.
*/
@OnLeaderTakeover()
async reconnectAll(): Promise<void> {
// Only reconnect integrations for published agents — an unpublished agent must not
// receive events, so we don't even load it.
const agents = await this.agentRepository.findPublished();
for (const agent of agents) {
if (!agent.integrations || agent.integrations.length === 0) continue;
for (const integration of agent.integrations) {
const definition = this.integrationRegistry.get(integration.type);
if (definition?.requiresLeader() && !this.instanceSettings.isLeader) {
this.logger.debug(
`[ChatIntegrationService] Skipping ${integration.type} for agent ${agent.id} — leader-only and this main is a follower`,
);
continue;
}
const key = this.connectionKey(agent.id, integration.type, integration.credentialId);
if (this.connections.has(key)) continue;
// External setup runs once per cluster — the leader claims that role
// on startup; followers only build local runtime state.
const skipExternalHooks = !this.instanceSettings.isLeader;
const options = this.connectOptionsFor(integration, skipExternalHooks);
try {
await this.connect(agent.id, integration, agent.projectId, options);
} catch (error) {
this.logger.error(
`[ChatIntegrationService] Failed to reconnect ${integration.type} for agent ${agent.id} — credential not accessible to the project: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
async startChannel(agent: Agent, integration: AgentIntegrationConfig): Promise<void> {
const skipExternalHooks = !this.instanceSettings.isLeader;
await this.connect(
agent.id,
integration,
agent.projectId,
this.connectOptionsFor(integration, skipExternalHooks),
);
}
/**
@@ -917,6 +996,14 @@ export class ChatIntegrationService {
// Private helpers
// ---------------------------------------------------------------------------
private channelRef(agentId: string, integration: AgentIntegrationConfig): AgentChannelRef {
return {
agentId,
integrationType: integration.type,
credentialId: integration.credentialId,
};
}
/**
* Whether this main has to hand the operation to the leader instead of running
* it locally. Whether ingress makes a connection leader-bound is the
@@ -1072,6 +1159,13 @@ export class ChatIntegrationService {
// main releases the local runtime state it built for this connection.
await this.runDisconnectedHook(this.integrationFromConnectionKey(key), conn.context, key);
// Every teardown reaches here, and every one of them means the same thing:
// this main is no longer running this channel, so it has nothing left to say
// about it. That holds for a channel being removed and for a purely local
// release — leader stepdown, a peer applying a broadcast — because the row
// is this process's account, not the cluster's.
await this.statusReporter.withdraw(conn.ref);
this.logger.info(`[ChatIntegrationService] Disconnected: ${key}`);
}
@@ -0,0 +1,33 @@
import { ConflictError } from '@/errors/response-errors/conflict.error';
import type { AgentChannelPreconditionContext } from './agent-chat-integration';
import type { AgentRepository } from '../repositories/agent.repository';
/**
* Reject a channel whose credential another agent already claims.
*
* A bot credential delivers events to exactly one destination, so a second
* agent connecting the same one takes the first agent's channel over silently.
* Every platform that owns its credential this way shares this check, and
* shares one message: it names the agent holding the credential and what to do
* about it, because the only fix is a decision the user has to make.
*/
export async function assertCredentialNotClaimed(
agentRepository: AgentRepository,
displayLabel: string,
type: string,
ctx: AgentChannelPreconditionContext,
): Promise<void> {
const others = await agentRepository.findByIntegrationCredential(
type,
ctx.credentialId,
ctx.projectId,
ctx.agentId,
);
if (others.length === 0) return;
throw new ConflictError(
`This ${displayLabel} credential is already connected to agent "${others[0].name}". ` +
`Disconnect the channel there, or connect this agent with a different ${displayLabel} credential.`,
);
}
@@ -13,6 +13,7 @@ import { ConflictError } from '@/errors/response-errors/conflict.error';
import { AgentRepository } from '../../repositories/agent.repository';
import {
AgentChatIntegration,
type AgentChannelPreconditionContext,
type AgentChatIntegrationContext,
type ActionDecisionMessageParams,
type BridgeExecutionContext,
@@ -26,6 +27,7 @@ import {
} from '../agent-chat-integration';
import type { ChatInstance } from '../chat-integration.service';
import type { SuspendComponent } from '../component-mapper';
import { assertCredentialNotClaimed } from '../credential-claim';
import { loadDiscordAdapter } from '../esm-loader';
import type { ReplyExpectation } from '../integration-tools';
import {
@@ -194,23 +196,21 @@ export class DiscordIntegration extends AgentChatIntegration {
});
}
async assertStartupPreconditions(ctx: AgentChannelPreconditionContext): Promise<void> {
await assertCredentialNotClaimed(this.agentRepository, this.displayLabel, this.type, ctx);
}
/**
* Reject connect when another agent already owns this credential, then
* verify the bot token against Discord application metadata so a typo'd
* Application ID / Public Key fails before publish rather than at runtime.
*
* The token check stays out of `assertStartupPreconditions` on purpose: it
* calls Discord, so an outage there would otherwise block publishing an
* agent whose credential is perfectly fine.
*/
async onBeforeConnect(ctx: AgentChatIntegrationContext): Promise<void> {
const others = await this.agentRepository.findByIntegrationCredential(
this.type,
ctx.credentialId,
ctx.projectId,
ctx.agentId,
);
if (others.length > 0) {
throw new ConflictError(
`Discord credential is already connected to agent "${others[0].name}"`,
);
}
await this.assertStartupPreconditions(ctx);
await this.validateDiscordCredential(ctx);
}
@@ -7,11 +7,10 @@ import { Container, Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
import type { Thread } from 'chat';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { AgentRepository } from '../../../repositories/agent.repository';
import {
AgentChatIntegration,
type AgentChannelPreconditionContext,
type AgentChatIntegrationContext,
type AgentIntegrationRemovalContext,
type BridgeExecutionContext,
@@ -22,6 +21,7 @@ import {
type UnauthenticatedWebhookResponse,
} from '../../agent-chat-integration';
import type { ChatInstance } from '../../chat-integration.service';
import { assertCredentialNotClaimed } from '../../credential-claim';
import { loadSlackAdapter } from '../../esm-loader';
import { connectionUnavailable } from '../../integration-helpers';
import {
@@ -105,16 +105,12 @@ export class SlackIntegration extends AgentChatIntegration {
'do_not_respond',
]);
async assertStartupPreconditions(ctx: AgentChannelPreconditionContext): Promise<void> {
await assertCredentialNotClaimed(this.agentRepository, this.displayLabel, this.type, ctx);
}
async onBeforeConnect(ctx: AgentChatIntegrationContext): Promise<void> {
const others = await this.agentRepository.findByIntegrationCredential(
this.type,
ctx.credentialId,
ctx.projectId,
ctx.agentId,
);
if (others.length > 0) {
throw new ConflictError(`Slack credential is already connected to agent "${others[0].name}"`);
}
await this.assertStartupPreconditions(ctx);
}
async onRemove(
@@ -11,12 +11,12 @@ import { InstanceSettings } from 'n8n-core';
import { UnexpectedError } from 'n8n-workflow';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { UrlService } from '@/services/url.service';
import { AgentRepository } from '../../repositories/agent.repository';
import {
AgentChatIntegration,
type AgentChannelPreconditionContext,
type AgentChatIntegrationContext,
type ActionDecisionMessageParams,
type BridgeExecutionContext,
@@ -24,6 +24,7 @@ import {
type BridgeResumeExecutionContext,
} from '../agent-chat-integration';
import type { SuspendComponent } from '../component-mapper';
import { assertCredentialNotClaimed } from '../credential-claim';
import { loadTelegramAdapter } from '../esm-loader';
import { resolveIntegrationActionDefinitions } from '../integration-tool-definitions';
import {
@@ -175,23 +176,18 @@ export class TelegramIntegration extends AgentChatIntegration {
}
/**
* Block the connect flow if this Telegram credential is already claimed by
* another agent in our DB. We deliberately don't probe Telegram for an
* existing webhook here — `onAfterConnect` overwrites whatever URL Telegram
* has on file, so a stale webhook from elsewhere isn't a connect blocker.
* We deliberately don't probe Telegram for an existing webhook here —
* `onAfterConnect` overwrites whatever URL Telegram has on file, so a stale
* webhook from elsewhere isn't a connect blocker. That leaves the claim
* check, which reads only our own DB, so publishing can run it as a
* preflight.
*/
async assertStartupPreconditions(ctx: AgentChannelPreconditionContext): Promise<void> {
await assertCredentialNotClaimed(this.agentRepository, this.displayLabel, this.type, ctx);
}
async onBeforeConnect(ctx: AgentChatIntegrationContext): Promise<void> {
const others = await this.agentRepository.findByIntegrationCredential(
this.type,
ctx.credentialId,
ctx.projectId,
ctx.agentId,
);
if (others.length > 0) {
throw new ConflictError(
`Telegram credential is already connected to agent "${others[0].name}"`,
);
}
await this.assertStartupPreconditions(ctx);
}
async onAfterConnect(ctx: AgentChatIntegrationContext): Promise<void> {
@@ -0,0 +1,120 @@
import { Service } from '@n8n/di';
import { DataSource, LessThan, Repository } from '@n8n/typeorm';
import { InstanceSettings } from 'n8n-core';
import type { AgentChannelStatusValue } from '../entities/agent-channel-status.entity';
import { AgentChannelStatus } from '../entities/agent-channel-status.entity';
/** Identifies one channel of one agent, across all processes running it. */
export interface AgentChannelRef {
agentId: string;
integrationType: string;
credentialId: string;
}
/** What one process observed, as its own row records it. */
export interface AgentChannelObservation {
status: AgentChannelStatusValue;
errorMessage: string | null;
attempts: number;
backoffUntil: Date | null;
expiresAt: Date | null;
}
const CONFLICT_PATHS = ['agentId', 'integrationType', 'credentialId', 'hostId'] as const;
/**
* Startup errors carry whatever the platform or the adapter said, which can be a
* whole response body. Long enough to diagnose, short enough that a row stays
* readable and a UI can show it.
*/
const MAX_ERROR_MESSAGE_LENGTH = 1024;
/**
* Reads and writes what each process observed about the channels it runs.
*
* Every write goes to this process's own row, and the caller cannot say
* otherwise: the `hostId` comes from {@link InstanceSettings} rather than from a
* parameter, so "overwrite another main's row" is not a mistake this API can
* express. That single-writer rule is what keeps a reported status from
* contradicting itself when several mains run the same channel.
*
* The one exception is {@link deleteExpired}, which is explicit about crossing
* that line and only touches rows their own owner has stopped refreshing.
*/
@Service()
export class AgentChannelStatusRepository extends Repository<AgentChannelStatus> {
constructor(
dataSource: DataSource,
private readonly instanceSettings: InstanceSettings,
) {
super(AgentChannelStatus, dataSource.manager);
}
/**
* Write this process's account of one channel.
*
* `updatedAt` is passed explicitly because TypeORM only overwrites columns
* present in the value literal on conflict. The message is capped here rather
* than at the call sites so no caller can bloat a row with a response body.
*/
async saveOwn(ref: AgentChannelRef, observation: AgentChannelObservation): Promise<void> {
await this.upsert(
{
...this.own(ref),
...observation,
errorMessage: observation.errorMessage?.slice(0, MAX_ERROR_MESSAGE_LENGTH) ?? null,
updatedAt: new Date(),
},
[...CONFLICT_PATHS],
);
}
/** Keep this process's row counting, without disturbing its retry deadline. */
async refreshOwnLease(ref: AgentChannelRef, expiresAt: Date | null): Promise<void> {
await this.update(this.own(ref), { expiresAt, updatedAt: new Date() });
}
/**
* Withdraw what this process said about a channel, because it is no longer
* running it — whether the channel was removed, the agent unpublished, or
* ownership moved to another main.
*/
async clearOwnChannel(ref: AgentChannelRef): Promise<void> {
await this.delete(this.own(ref));
}
/** Withdraw everything this process said, on its way out. */
async clearOwnHost(): Promise<void> {
await this.delete({ hostId: this.instanceSettings.hostId });
}
async findOwnChannel(ref: AgentChannelRef): Promise<AgentChannelStatus | null> {
return await this.findOneBy(this.own(ref));
}
/** This process's own rows, for deciding what to retry and what to refresh. */
async findOwnAll(): Promise<AgentChannelStatus[]> {
return await this.findBy({ hostId: this.instanceSettings.hostId });
}
/** Every process's account of this agent's channels, for reporting. */
async findByAgentId(agentId: string): Promise<AgentChannelStatus[]> {
return await this.findBy({ agentId });
}
/**
* Rows whose owner stopped refreshing them: it crashed, or it was killed
* before it could withdraw them. `hostId` is regenerated on restart, so the
* owner will never come back to clean up after itself. Rows with no expiry are
* left alone — nothing is refreshing them by design.
*/
async deleteExpired(now: Date): Promise<number> {
const { affected } = await this.delete({ expiresAt: LessThan(now) });
return affected ?? 0;
}
private own(ref: AgentChannelRef) {
return { ...ref, hostId: this.instanceSettings.hostId };
}
}
@@ -0,0 +1,246 @@
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
import { Container } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import { v4 as uuid } from 'uuid';
import type { Agent } from '@/modules/agents/entities/agent.entity';
import type { AgentChannelObservation } from '@/modules/agents/repositories/agent-channel-status.repository';
import {
AgentChannelStatusRepository,
type AgentChannelRef,
} from '@/modules/agents/repositories/agent-channel-status.repository';
import { AgentRepository } from '@/modules/agents/repositories/agent.repository';
const CONNECTED: AgentChannelObservation = {
status: 'connected',
errorMessage: null,
attempts: 0,
backoffUntil: null,
expiresAt: null,
};
function errored(overrides: Partial<AgentChannelObservation> = {}): AgentChannelObservation {
return {
status: 'error',
errorMessage: 'boom',
attempts: 2,
backoffUntil: new Date('2026-01-01T00:00:00.000Z'),
expiresAt: null,
...overrides,
};
}
describe('AgentChannelStatusRepository', () => {
let statusRepo: AgentChannelStatusRepository;
let agentRepo: AgentRepository;
let instanceSettings: InstanceSettings;
let agent: Agent;
let ref: AgentChannelRef;
/**
* Write a row as if another process had: `hostId` is deliberately not a
* parameter of the repository's own writes, so this reaches past it to set up
* the multi-instance cases.
*/
async function saveAsOtherHost(
hostId: string,
channel: AgentChannelRef,
observation: AgentChannelObservation,
) {
await statusRepo.insert({ ...channel, hostId, ...observation });
}
beforeAll(async () => {
await testModules.loadModules(['agents']);
await testDb.init();
statusRepo = Container.get(AgentChannelStatusRepository);
agentRepo = Container.get(AgentRepository);
instanceSettings = Container.get(InstanceSettings);
});
beforeEach(async () => {
const project = await createTeamProject();
agent = await agentRepo.save(
agentRepo.create({
id: uuid(),
name: 'Test Agent',
projectId: project.id,
integrations: [],
tools: {},
skills: {},
versionId: 'version-1',
activeVersionId: null,
} as Partial<Agent>),
);
ref = { agentId: agent.id, integrationType: 'telegram', credentialId: 'cred-1' };
});
afterEach(async () => {
await statusRepo.delete({});
await agentRepo.delete({});
});
afterAll(async () => {
await testDb.terminate();
});
it('writes this instances account of a channel', async () => {
await statusRepo.saveOwn(ref, CONNECTED);
await expect(statusRepo.findOwnChannel(ref)).resolves.toMatchObject({
...ref,
hostId: instanceSettings.hostId,
status: 'connected',
errorMessage: null,
attempts: 0,
});
});
it('replaces its own row rather than adding one', async () => {
await statusRepo.saveOwn(ref, errored());
await statusRepo.saveOwn(ref, CONNECTED);
const rows = await statusRepo.findByAgentId(agent.id);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ status: 'connected', errorMessage: null, attempts: 0 });
});
it('round-trips the retry deadline and the lease', async () => {
const backoffUntil = new Date('2026-06-01T12:00:00.000Z');
const expiresAt = new Date('2026-06-01T12:05:00.000Z');
await statusRepo.saveOwn(ref, errored({ backoffUntil, expiresAt }));
const row = await statusRepo.findOwnChannel(ref);
expect(row?.backoffUntil?.toISOString()).toBe(backoffUntil.toISOString());
expect(row?.expiresAt?.toISOString()).toBe(expiresAt.toISOString());
});
it('caps an error message too long to be readable', async () => {
await statusRepo.saveOwn(ref, errored({ errorMessage: 'x'.repeat(5000) }));
const row = await statusRepo.findOwnChannel(ref);
expect(row?.errorMessage).toHaveLength(1024);
});
describe('per-instance isolation', () => {
it('keeps one row per instance for the same channel', async () => {
await statusRepo.saveOwn(ref, CONNECTED);
await saveAsOtherHost('main-other', ref, errored());
const rows = await statusRepo.findByAgentId(agent.id);
expect(rows).toHaveLength(2);
expect(rows.map((row) => row.status).sort()).toEqual(['connected', 'error']);
});
it('does not overwrite another instances row when writing its own', async () => {
await saveAsOtherHost('main-other', ref, errored({ errorMessage: 'their failure' }));
await statusRepo.saveOwn(ref, CONNECTED);
const theirs = await statusRepo.findOneBy({ ...ref, hostId: 'main-other' });
expect(theirs).toMatchObject({ status: 'error', errorMessage: 'their failure' });
});
it('reads back only its own rows', async () => {
await statusRepo.saveOwn(ref, CONNECTED);
await saveAsOtherHost('main-other', ref, errored());
const own = await statusRepo.findOwnAll();
expect(own).toHaveLength(1);
expect(own[0].hostId).toBe(instanceSettings.hostId);
});
it('withdraws only its own row for a channel', async () => {
await statusRepo.saveOwn(ref, CONNECTED);
await saveAsOtherHost('main-other', ref, CONNECTED);
await statusRepo.clearOwnChannel(ref);
const rows = await statusRepo.findByAgentId(agent.id);
expect(rows).toHaveLength(1);
expect(rows[0].hostId).toBe('main-other');
});
it('withdraws all of its own rows without touching anyone elses', async () => {
await statusRepo.saveOwn(ref, CONNECTED);
await statusRepo.saveOwn({ ...ref, integrationType: 'slack' }, CONNECTED);
await saveAsOtherHost('main-other', ref, CONNECTED);
await statusRepo.clearOwnHost();
const rows = await statusRepo.findByAgentId(agent.id);
expect(rows).toHaveLength(1);
expect(rows[0].hostId).toBe('main-other');
});
});
describe('leases', () => {
it('extends its own lease without disturbing the retry deadline', async () => {
const backoffUntil = new Date('2026-06-01T12:00:00.000Z');
await statusRepo.saveOwn(ref, errored({ backoffUntil }));
const expiresAt = new Date('2026-06-01T13:00:00.000Z');
await statusRepo.refreshOwnLease(ref, expiresAt);
const row = await statusRepo.findOwnChannel(ref);
expect(row?.expiresAt?.toISOString()).toBe(expiresAt.toISOString());
expect(row?.backoffUntil?.toISOString()).toBe(backoffUntil.toISOString());
expect(row?.status).toBe('error');
expect(row?.attempts).toBe(2);
});
it('deletes rows past their expiry, whoever owns them', async () => {
const past = new Date(Date.now() - 60_000);
await saveAsOtherHost('main-dead', ref, CONNECTED);
await saveAsOtherHost(
'main-gone',
{ ...ref, integrationType: 'slack' },
{
...CONNECTED,
expiresAt: past,
},
);
const deleted = await statusRepo.deleteExpired(new Date());
expect(deleted).toBe(1);
const remaining = await statusRepo.findByAgentId(agent.id);
expect(remaining).toHaveLength(1);
expect(remaining[0].hostId).toBe('main-dead');
});
it('leaves rows with no expiry alone, since nothing is refreshing them by design', async () => {
await statusRepo.saveOwn(ref, { ...CONNECTED, expiresAt: null });
await expect(statusRepo.deleteExpired(new Date())).resolves.toBe(0);
await expect(statusRepo.findOwnChannel(ref)).resolves.not.toBeNull();
});
it('keeps a row whose lease still has time on it', async () => {
await statusRepo.saveOwn(ref, {
...CONNECTED,
expiresAt: new Date(Date.now() + 60_000),
});
await expect(statusRepo.deleteExpired(new Date())).resolves.toBe(0);
});
});
it('keeps one row per channel of an agent', async () => {
await statusRepo.saveOwn(ref, CONNECTED);
await statusRepo.saveOwn({ ...ref, integrationType: 'slack' }, errored());
await statusRepo.saveOwn({ ...ref, credentialId: 'cred-2' }, CONNECTED);
await expect(statusRepo.findByAgentId(agent.id)).resolves.toHaveLength(3);
});
it('goes away with its agent, including other instances rows', async () => {
await statusRepo.saveOwn(ref, CONNECTED);
await saveAsOtherHost('main-other', ref, CONNECTED);
await agentRepo.delete({ id: agent.id });
await expect(statusRepo.findByAgentId(agent.id)).resolves.toEqual([]);
});
});
@@ -8255,6 +8255,8 @@
"agents.channels.modal.editPlaceholder": "Edit view for {channel} will go here.",
"agents.channels.modal.configured": "Configured",
"agents.channels.modal.connected": "Connected",
"agents.channels.modal.notRunning": "Not running",
"agents.channels.modal.notRunning.tooltip": "This channel couldn't be started. n8n keeps retrying it.",
"agents.channels.modal.removeChannel": "Remove channel",
"agents.channels.modal.saveChannelError": "Channel couldn't be saved. Try again.",
"agents.channels.modal.removeChannelError": "Channel couldn't be removed. Try again.",
@@ -14,13 +14,18 @@ const integration = {
credentialTypes: ['slackOAuth2Api'],
};
function mountItem(configured: boolean, connected: boolean) {
function mountItem(
configured: boolean,
connected: boolean,
extra: { notRunning?: boolean; runtimeError?: string } = {},
) {
return mount(AgentChannelListItem, {
props: {
integration,
configured,
connected,
connectAction: { label: 'generic.connect' },
...extra,
},
global: {
stubs: {
@@ -30,6 +35,10 @@ function mountItem(configured: boolean, connected: boolean) {
},
N8nIcon: { template: '<i />' },
N8nText: { template: '<span><slot /></span>' },
N8nTooltip: {
props: ['content', 'disabled'],
template: '<div :data-tooltip="content" :data-tooltip-disabled="disabled"><slot /></div>',
},
},
},
});
@@ -49,6 +58,47 @@ describe('AgentChannelListItem', () => {
);
});
describe('a channel that failed to start', () => {
it('reads as not running rather than configured', () => {
const wrapper = mountItem(true, false, { notRunning: true });
expect(wrapper.text()).toContain('agents.channels.modal.notRunning');
expect(wrapper.text()).not.toContain('agents.channels.modal.configured');
expect(wrapper.find('[data-testid="agent-channel-not-running-indicator"]').exists()).toBe(
true,
);
expect(wrapper.find('[data-testid="agent-channel-connected-indicator"]').exists()).toBe(
false,
);
});
it('explains why on hover', () => {
const wrapper = mountItem(true, false, {
notRunning: true,
runtimeError: 'This Telegram credential is already connected to agent "Support"',
});
expect(wrapper.get('[data-tooltip]').attributes('data-tooltip')).toBe(
'This Telegram credential is already connected to agent "Support"',
);
expect(wrapper.get('[data-tooltip]').attributes('data-tooltip-disabled')).toBe('false');
});
it('still says something when the failure came with no message', () => {
const wrapper = mountItem(true, false, { notRunning: true });
expect(wrapper.get('[data-tooltip]').attributes('data-tooltip')).toBe(
'agents.channels.modal.notRunning.tooltip',
);
});
it('leaves the tooltip off a healthy channel', () => {
const wrapper = mountItem(true, true);
expect(wrapper.get('[data-tooltip]').attributes('data-tooltip-disabled')).toBe('true');
});
});
it('renders registry-provided connect action metadata', () => {
const wrapper = mount(AgentChannelListItem, {
props: {
@@ -28,10 +28,13 @@ const slackIntegration = {
icon: 'slack',
credentialTypes: ['slackApi'],
};
const statuses = ref<Record<string, 'configured' | 'connected' | 'disconnected'>>({});
const statuses = ref<
Record<string, 'configured' | 'starting' | 'connected' | 'error' | 'disconnected'>
>({});
const connectedCredentials = ref<Record<string, string>>({});
const selectedCredentials = ref<Record<string, string>>({});
const loadingMap = ref<Record<string, boolean>>({});
const runtimeErrors = ref<Record<string, string>>({});
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (key: string) => key }),
@@ -146,9 +149,14 @@ vi.mock('../composables/useAgentIntegrationStatus', () => ({
loadingMap,
errorMessages: ref({}),
errorIsConflict: ref({}),
runtimeErrors,
isConnected: (type: string) => statuses.value[type] === 'connected',
isConfigured: (type: string) =>
['configured', 'connected'].includes(statuses.value[type] ?? 'disconnected'),
['configured', 'starting', 'connected', 'error'].includes(
statuses.value[type] ?? 'disconnected',
),
hasRuntimeError: (type: string) => statuses.value[type] === 'error',
isStarting: (type: string) => statuses.value[type] === 'starting',
connect: mocks.connect,
disconnect: mocks.disconnect,
clearError: mocks.clearError,
@@ -203,7 +211,14 @@ function mountModal(view: ChannelView = 'example_setup', isPublished = false) {
N8nIcon: { template: '<i />' },
N8nText: { template: '<span><slot /></span>' },
AgentChannelListItem: {
props: ['integration', 'configured', 'connected', 'connectAction'],
props: [
'integration',
'configured',
'connected',
'notRunning',
'runtimeError',
'connectAction',
],
emits: ['setup', 'disconnect'],
template: `
<li
@@ -211,6 +226,8 @@ function mountModal(view: ChannelView = 'example_setup', isPublished = false) {
:data-action="connectAction.label"
:data-configured="configured"
:data-connected="connected"
:data-not-running="notRunning"
:data-runtime-error="runtimeError"
>
<button data-testid="setup-channel" @click="$emit('setup', integration.type)" />
<button data-testid="disconnect-channel" @click="$emit('disconnect', integration.type)" />
@@ -230,6 +247,7 @@ describe('AgentChannelModal', () => {
connectedCredentials.value = {};
selectedCredentials.value = {};
loadingMap.value = {};
runtimeErrors.value = {};
mocks.connect.mockImplementation(async (type: string, credentialId: string) => {
statuses.value[type] = 'connected';
connectedCredentials.value[type] = credentialId;
@@ -288,6 +306,22 @@ describe('AgentChannelModal', () => {
);
});
it('shows a channel that failed to start as not running, with the reason', async () => {
statuses.value.example = 'error';
runtimeErrors.value.example = 'Credential cred-1 not found';
const wrapper = mountModal('list');
await flushPromises();
expect(wrapper.get('[data-testid="channel-list-item"]').attributes()).toMatchObject({
// Still set up, so the row keeps its Edit/Disconnect menu rather than
// offering to connect a channel that already exists.
'data-configured': 'true',
'data-connected': 'false',
'data-not-running': 'true',
'data-runtime-error': 'Credential cred-1 not found',
});
});
it('forwards publication state and persists before platform save', async () => {
selectedCredentials.value.example = 'credential-new';
const wrapper = mountModal('example_setup', true);
@@ -30,19 +30,67 @@ describe('useAgentIntegrationStatus', () => {
it.each([
{ serverStatus: 'configured' as const, connected: false },
{ serverStatus: 'starting' as const, connected: false },
{ serverStatus: 'connected' as const, connected: true },
])('tracks a $serverStatus integration', async ({ serverStatus, connected }) => {
{ serverStatus: 'error' as const, connected: false },
])('tracks a $serverStatus channel', async ({ serverStatus, connected }) => {
apiMocks.getIntegrationStatus.mockResolvedValue({
status: serverStatus,
integrations: [{ type: 'slack', credentialId: 'cred-slack' }],
integrations: [{ type: 'slack', credentialId: 'cred-slack', status: serverStatus }],
});
const status = useAgentIntegrationStatus(projectId, agentId);
await status.fetchStatus(['slack']);
expect(status.statuses.value.slack).toBe(serverStatus);
// Every one of these means the channel is set up, so the list keeps
// offering Edit and Disconnect rather than a Connect button.
expect(status.isConfigured('slack')).toBe(true);
expect(status.isConnected('slack')).toBe(connected);
expect(status.hasRuntimeError('slack')).toBe(serverStatus === 'error');
});
it('takes each channel from its own status, not the rollup', async () => {
apiMocks.getIntegrationStatus.mockResolvedValue({
status: 'partial',
integrations: [
{ type: 'slack', credentialId: 'cred-slack', status: 'connected' },
{
type: 'telegram',
credentialId: 'cred-telegram',
status: 'error',
errorMessage: 'Credential cred-telegram not found',
},
],
});
const status = useAgentIntegrationStatus(projectId, agentId);
await status.fetchStatus(['slack', 'telegram']);
expect(status.isConnected('slack')).toBe(true);
expect(status.hasRuntimeError('telegram')).toBe(true);
expect(status.runtimeErrors.value.telegram).toBe('Credential cred-telegram not found');
});
it('drops a stale runtime error once the channel starts', async () => {
const status = useAgentIntegrationStatus(projectId, agentId);
apiMocks.getIntegrationStatus.mockResolvedValue({
status: 'error',
integrations: [
{ type: 'slack', credentialId: 'cred-slack', status: 'error', errorMessage: 'boom' },
],
});
await status.fetchStatus(['slack']);
expect(status.runtimeErrors.value.slack).toBe('boom');
apiMocks.getIntegrationStatus.mockResolvedValue({
status: 'connected',
integrations: [{ type: 'slack', credentialId: 'cred-slack', status: 'connected' }],
});
await status.fetchStatus(['slack']);
expect(status.runtimeErrors.value.slack).toBe('');
expect(status.hasRuntimeError('slack')).toBe(false);
});
it('uses the configuration response status after saving an integration', async () => {
@@ -59,33 +107,203 @@ describe('useAgentIntegrationStatus', () => {
expect(status.isConnected('telegram')).toBe(false);
});
it('preserves confirmed configured and connected states when a refresh fails', async () => {
it('does not pass a locally-seeded status off as a server answer', async () => {
// The builder seeds this cache from the agent's own configuration so the
// panel can render before the status endpoint replies. If that request then
// fails, the seeded guess must not be preserved as though it were confirmed.
syncAgentIntegrationStatusCache(
projectId,
agentId,
['slack'],
[{ type: 'slack', credentialId: 'cred-slack' }],
'configured',
);
syncAgentIntegrationStatusCache(
projectId,
agentId,
['linear'],
[{ type: 'linear', credentialId: 'cred-linear' }],
'connected',
[{ type: 'slack', credentialId: 'cred-slack', status: 'starting' }],
);
apiMocks.getIntegrationStatus.mockRejectedValue(new Error('network error'));
const status = useAgentIntegrationStatus(projectId, agentId);
await status.fetchStatus(['slack', 'linear', 'telegram']);
await status.fetchStatus(['slack']);
expect(status.statuses.value.slack).toBe('unknown');
});
it('keeps a server-confirmed starting status when a later refresh fails', async () => {
apiMocks.getIntegrationStatus.mockResolvedValue({
status: 'partial',
integrations: [{ type: 'slack', credentialId: 'cred-slack', status: 'starting' }],
});
const status = useAgentIntegrationStatus(projectId, agentId);
await status.fetchStatus(['slack']);
apiMocks.getIntegrationStatus.mockRejectedValue(new Error('network error'));
await status.fetchStatus(['slack']);
expect(status.statuses.value.slack).toBe('starting');
});
it('preserves states the server already confirmed when a refresh fails', async () => {
apiMocks.getIntegrationStatus.mockResolvedValue({
status: 'partial',
integrations: [
{ type: 'slack', credentialId: 'cred-slack', status: 'configured' },
{ type: 'linear', credentialId: 'cred-linear', status: 'connected' },
{ type: 'discord', credentialId: 'cred-discord', status: 'error' },
],
});
const status = useAgentIntegrationStatus(projectId, agentId);
await status.fetchStatus(['slack', 'linear', 'discord']);
apiMocks.getIntegrationStatus.mockRejectedValue(new Error('network error'));
await status.fetchStatus(['slack', 'linear', 'discord', 'telegram']);
expect(status.statuses.value).toMatchObject({
slack: 'configured',
linear: 'connected',
// A failed refresh must not turn a known failure into a shrug.
discord: 'error',
// Never answered for, so there is nothing to preserve.
telegram: 'unknown',
});
});
it('keeps a connect result when a later refresh fails, since the server did answer', async () => {
apiMocks.connectIntegration.mockResolvedValue({ status: 'connected' });
const status = useAgentIntegrationStatus(projectId, agentId);
await status.connect('slack', 'cred-slack');
apiMocks.getIntegrationStatus.mockRejectedValue(new Error('network error'));
await status.fetchStatus(['slack']);
expect(status.statuses.value.slack).toBe('connected');
});
it('keeps a disconnect result when a later refresh fails', async () => {
apiMocks.disconnectIntegration.mockResolvedValue({ status: 'disconnected' });
const status = useAgentIntegrationStatus(projectId, agentId);
await status.disconnect('slack', 'cred-slack');
apiMocks.getIntegrationStatus.mockRejectedValue(new Error('network error'));
await status.fetchStatus(['slack']);
expect(status.statuses.value.slack).toBe('disconnected');
});
it('does not let a builder re-seed downgrade a channel the server confirmed', async () => {
// Every builder write re-seeds this cache from local configuration, where a
// published agent's channels read as `starting`. That guess must not replace
// what the status endpoint said — nothing on that path refetches to correct it.
apiMocks.getIntegrationStatus.mockResolvedValue({
status: 'partial',
integrations: [
{ type: 'slack', credentialId: 'cred-slack', status: 'connected' },
{ type: 'discord', credentialId: 'cred-discord', status: 'error', errorMessage: 'boom' },
],
});
const status = useAgentIntegrationStatus(projectId, agentId);
await status.fetchStatus(['slack', 'discord']);
syncAgentIntegrationStatusCache(
projectId,
agentId,
['slack', 'discord'],
[
{ type: 'slack', credentialId: 'cred-slack', status: 'starting' },
{ type: 'discord', credentialId: 'cred-discord', status: 'starting' },
],
);
expect(status.statuses.value.slack).toBe('connected');
expect(status.statuses.value.discord).toBe('error');
expect(status.runtimeErrors.value.discord).toBe('boom');
});
it('lets an unpublish seed retire a channel the server reported as running', async () => {
// Unpublishing is configuration's own fact, and the channels of an
// unpublished agent are not running whatever they were doing before — so the
// seed's `configured` outranks the earlier `connected`, and the error of a
// channel that had failed goes with it.
apiMocks.getIntegrationStatus.mockResolvedValue({
status: 'partial',
integrations: [
{ type: 'slack', credentialId: 'cred-slack', status: 'connected' },
{ type: 'discord', credentialId: 'cred-discord', status: 'error', errorMessage: 'boom' },
],
});
const status = useAgentIntegrationStatus(projectId, agentId);
await status.fetchStatus(['slack', 'discord']);
syncAgentIntegrationStatusCache(
projectId,
agentId,
['slack', 'discord'],
[
{ type: 'slack', credentialId: 'cred-slack', status: 'configured' },
{ type: 'discord', credentialId: 'cred-discord', status: 'configured' },
],
);
expect(status.statuses.value.slack).toBe('configured');
expect(status.statuses.value.discord).toBe('configured');
expect(status.runtimeErrors.value.discord).toBe('');
});
it('still takes the credential and settings of a confirmed channel from configuration', async () => {
// Only the status is the server's to know; what the channel is set up with is
// the builder's own write, which is what the seed exists to carry.
apiMocks.getIntegrationStatus.mockResolvedValue({
status: 'connected',
integrations: [{ type: 'telegram', credentialId: 'cred-old', status: 'connected' }],
});
const status = useAgentIntegrationStatus(projectId, agentId);
await status.fetchStatus(['telegram']);
syncAgentIntegrationStatusCache(
projectId,
agentId,
['telegram'],
[{ type: 'telegram', credentialId: 'cred-new', status: 'starting' }],
);
expect(status.statuses.value.telegram).toBe('connected');
expect(status.connectedCredentials.value.telegram).toBe('cred-new');
});
it('seeds a channel the server last reported as absent', async () => {
// The answer was about a channel that did not exist then. Configuration has
// one now — just added in the builder — so the seed is the fresher account.
apiMocks.getIntegrationStatus.mockResolvedValue({ status: 'configured', integrations: [] });
const status = useAgentIntegrationStatus(projectId, agentId);
await status.fetchStatus(['slack']);
expect(status.statuses.value.slack).toBe('disconnected');
syncAgentIntegrationStatusCache(
projectId,
agentId,
['slack'],
[{ type: 'slack', credentialId: 'cred-slack', status: 'starting' }],
);
expect(status.statuses.value.slack).toBe('starting');
});
it('forgets a confirmed channel that configuration no longer has', async () => {
apiMocks.getIntegrationStatus.mockResolvedValue({
status: 'connected',
integrations: [{ type: 'slack', credentialId: 'cred-slack', status: 'connected' }],
});
const status = useAgentIntegrationStatus(projectId, agentId);
await status.fetchStatus(['slack']);
// Removed in the builder.
syncAgentIntegrationStatusCache(projectId, agentId, ['slack'], []);
expect(status.statuses.value.slack).toBe('disconnected');
// And the stale answer is gone with it, so a failed refresh has nothing to
// preserve rather than resurrecting a channel that no longer exists.
apiMocks.getIntegrationStatus.mockRejectedValue(new Error('network error'));
await status.fetchStatus(['slack']);
expect(status.statuses.value.slack).toBe('unknown');
});
it('clears a cached integration error', async () => {
apiMocks.connectIntegration.mockRejectedValue(
new ResponseError('Slack credential is already connected', { httpStatusCode: 409 }),
@@ -5,6 +5,7 @@ import {
N8nIcon,
N8nLoading,
N8nText,
N8nTooltip,
updatedIconSet,
type DropdownMenuItemProps,
type IconName,
@@ -22,6 +23,13 @@ interface Props {
connected: boolean;
connectAction: AgentChannelConnectAction;
loading?: boolean;
/**
* Set up and meant to be running, but the last startup attempt failed. Never
* true together with `connected`.
*/
notRunning?: boolean;
/** Why it isn't running, shown on hover. */
runtimeError?: string;
}
const props = defineProps<Props>();
@@ -53,6 +61,22 @@ function isIconName(icon: string): icon is IconName {
return icon in updatedIconSet;
}
const statusLabel = computed(() => {
if (props.notRunning) return i18n.baseText('agents.channels.modal.notRunning');
if (props.connected) return i18n.baseText('agents.channels.modal.connected');
return i18n.baseText('agents.channels.modal.configured');
});
/**
* The tooltip is the only place the startup error is shown, so it must not be
* empty when there is one to explain — fall back to generic copy if the server
* reported a failure without a message.
*/
const statusTooltip = computed(() => {
if (!props.notRunning) return '';
return props.runtimeError || i18n.baseText('agents.channels.modal.notRunning.tooltip');
});
function handleConfiguredAction(action: ChannelAction) {
if (action === 'edit') {
emit('edit', props.integration.type);
@@ -100,23 +124,26 @@ function handleConfiguredAction(action: ChannelAction) {
@select="handleConfiguredAction"
>
<template #trigger>
<N8nButton variant="ghost" size="medium" :class="$style.connectedTrigger">
<div
v-if="connected"
:class="$style.connectedDotContainer"
data-testid="agent-channel-connected-indicator"
>
<span :class="[$style.connectedDot, $style.ping]" />
<span :class="$style.connectedDot" />
</div>
{{
i18n.baseText(
connected
? 'agents.channels.modal.connected'
: 'agents.channels.modal.configured',
)
}}
</N8nButton>
<N8nTooltip :content="statusTooltip" :disabled="!notRunning" placement="top">
<N8nButton variant="ghost" size="medium" :class="$style.connectedTrigger">
<div
v-if="connected"
:class="$style.connectedDotContainer"
data-testid="agent-channel-connected-indicator"
>
<span :class="[$style.connectedDot, $style.ping]" />
<span :class="$style.connectedDot" />
</div>
<div
v-else-if="notRunning"
:class="$style.connectedDotContainer"
data-testid="agent-channel-not-running-indicator"
>
<span :class="[$style.connectedDot, $style.notRunningDot]" />
</div>
{{ statusLabel }}
</N8nButton>
</N8nTooltip>
</template>
</N8nDropdownMenu>
<N8nButton
@@ -215,6 +242,9 @@ function handleConfiguredAction(action: ChannelAction) {
border-radius: var(--radius--full);
background: var(--color--green-500);
}
.notRunningDot {
background: var(--color--danger);
}
.ping {
@include motion.ping;
}
@@ -66,8 +66,10 @@ const {
loadingMap,
errorMessages,
errorIsConflict,
runtimeErrors,
isConnected: isIntegrationConnected,
isConfigured: isIntegrationConfigured,
hasRuntimeError,
connect,
disconnect,
clearError: clearIntegrationError,
@@ -598,6 +600,8 @@ watch(
:integration="integration"
:configured="isConfigured(integration.type)"
:connected="isConnected(integration.type)"
:not-running="hasRuntimeError(integration.type)"
:runtime-error="runtimeErrors[integration.type]"
:loading="listLoading"
:connect-action="connectAction(integration.type)"
@setup="goToSetup"
@@ -42,10 +42,8 @@ const emit = defineEmits<{
const i18n = useI18n();
const credentialsStore = useCredentialsStore();
const { catalog, ensureLoaded } = useAgentIntegrationsCatalog();
const { connectedCredentials, fetchStatus } = useAgentIntegrationStatus(
props.projectId,
props.agentId,
);
const { connectedCredentials, runtimeErrors, hasRuntimeError, fetchStatus } =
useAgentIntegrationStatus(props.projectId, props.agentId);
const credentialNamesById = ref<Record<string, string>>({});
const channelModalOpen = ref(false);
@@ -84,11 +82,20 @@ const channelIssueMessages = computed(() => {
return messages;
});
function channelRuntimeErrorMessage(channel: string): string {
return runtimeErrors.value[channel] || i18n.baseText('agents.channels.modal.notRunning.tooltip');
}
const channelRows = computed(() =>
props.connectedTriggers.map((channel) => {
const integration = catalog.value?.find(({ type }) => type === channel);
const credentialId = connectedCredentials.value[channel];
const invalidReasons = channelIssueMessages.value.get(channel) ?? [];
// A channel that is configured correctly but failed to start is just as
// broken from here as a misconfigured one, so it uses the same affordance.
const invalidReasons = [
...(channelIssueMessages.value.get(channel) ?? []),
...(hasRuntimeError(channel) ? [channelRuntimeErrorMessage(channel)] : []),
];
return {
type: channel,
label: integration?.label ?? channel,
@@ -4,6 +4,7 @@ import type {
AgentConfigValidationResponse,
AgentDisconnectIntegrationResponse,
AgentFileDto,
AgentIntegrationConnectResponse,
AgentIntegrationStatusResponse,
AgentJsonVectorStoreConfig,
AgentSkill,
@@ -187,8 +188,8 @@ export const connectIntegration = async (
credentialId: string,
settings?: AgentIntegrationSettings,
options?: ConnectIntegrationOptions,
): Promise<Pick<AgentIntegrationStatusResponse, 'status'>> => {
return await makeRestApiRequest<Pick<AgentIntegrationStatusResponse, 'status'>>(
): Promise<AgentIntegrationConnectResponse> => {
return await makeRestApiRequest<AgentIntegrationConnectResponse>(
context,
'POST',
`/projects/${projectId}/agents/v2/${agentId}/integrations/connect`,
@@ -30,16 +30,27 @@ interface EditSnapshot {
connectedTriggers: string[];
}
/**
* The config says which channels exist, not whether they are running only the
* status endpoint knows that. A published agent's channels are therefore seeded
* as `starting` rather than `connected`: claiming connected here is what let a
* channel that never started show a green dot until something refetched.
*/
function integrationStatusEntriesFromConfig(
config: AgentJsonConfig | null,
knownTriggerTypes: readonly string[],
isPublished: boolean,
): Array<AgentIntegrationStatusEntry & { credentialId: string }> {
const knownTypes = new Set(knownTriggerTypes);
const entries: Array<AgentIntegrationStatusEntry & { credentialId: string }> = [];
for (const integration of config?.integrations ?? []) {
if (!knownTypes.has(integration.type)) continue;
entries.push({ type: integration.type, credentialId: integration.credentialId });
entries.push({
type: integration.type,
credentialId: integration.credentialId,
status: isPublished ? 'starting' : 'configured',
});
}
return entries;
@@ -114,6 +125,7 @@ export function useAgentBuilderTelemetry(deps: AgentBuilderTelemetryDeps) {
const integrations = integrationStatusEntriesFromConfig(
deps.localConfig.value,
knownTriggerTypes,
!!deps.agent.value?.activeVersionId,
);
const configured = integrations.map((integration) => integration.type).sort();
const configuredIntegrations = integrations.filter(
@@ -124,7 +136,6 @@ export function useAgentBuilderTelemetry(deps: AgentBuilderTelemetryDeps) {
deps.agentId.value,
knownTriggerTypes,
configuredIntegrations,
deps.agent.value?.activeVersionId ? 'connected' : 'configured',
);
return configured;
}
@@ -1,8 +1,9 @@
import { ref, type Ref } from 'vue';
import type {
AgentChannelRuntimeStatus,
AgentDisconnectIntegrationResponse,
AgentIntegrationConnectResponse,
AgentIntegrationStatusEntry,
AgentIntegrationStatusResponse,
AgentIntegrationSettings,
} from '@n8n/api-types';
import { ResponseError } from '@n8n/rest-api-client';
@@ -15,8 +16,12 @@ import {
type ConnectIntegrationOptions,
} from './useAgentApi';
type ConfirmedStatus = AgentIntegrationStatusResponse['status'];
type Status = ConfirmedStatus | 'unknown';
/**
* Per-channel state, plus the two answers only the client can give:
* `disconnected` for a channel that isn't set up at all, and `unknown` when we
* failed to ask.
*/
type Status = AgentChannelRuntimeStatus | 'disconnected' | 'unknown';
interface AgentIntegrationStatusState {
statuses: Ref<Record<string, Status>>;
@@ -25,6 +30,20 @@ interface AgentIntegrationStatusState {
loadingMap: Ref<Record<string, boolean>>;
errorMessages: Ref<Record<string, string>>;
errorIsConflict: Ref<Record<string, boolean>>;
/**
* Why a channel isn't running, from the server. Kept apart from
* `errorMessages`, which holds what the setup form's last attempt said the
* two have different lifetimes and are shown in different places.
*/
runtimeErrors: Ref<Record<string, string>>;
/**
* Channel types the server has actually answered for. A failed refetch must
* not overwrite what the server said, but it must not protect a guess either:
* the builder seeds this cache from local configuration alone, and treating
* that as an answer would leave the UI showing "Starting…" for a channel
* nobody has asked about yet.
*/
serverConfirmed: Ref<Set<string>>;
fetchInFlight: Promise<void> | null;
}
@@ -47,6 +66,8 @@ function getOrCreate(projectId: string, agentId: string): AgentIntegrationStatus
loadingMap: ref({}),
errorMessages: ref({}),
errorIsConflict: ref({}),
runtimeErrors: ref({}),
serverConfirmed: ref(new Set()),
fetchInFlight: null,
};
cache.set(key, state);
@@ -59,22 +80,70 @@ export function clearAgentIntegrationStatusCache(projectId: string, agentId: str
cache.delete(`${projectId}:${agentId}`);
}
/**
* Each entry carries its own status, so a mix of a running channel and a broken
* one renders as exactly that the response rollup is only for callers that
* want one word for the whole agent.
*
* `source` settles who wins where the two disagree. Configuration is the
* authority on which channels exist and what backs them; only the status
* endpoint knows whether one is actually running. So a `config` pass refreshes
* credentials and settings but leaves the status of a channel the server has
* already answered for alone otherwise any builder write, which re-seeds this
* cache, would downgrade a channel known to be `connected` (or known to have
* failed, losing its reason with it) to the local `starting` guess, and nothing
* on that path refetches to put it back.
*/
function applyStatus(
state: AgentIntegrationStatusState,
integrationTypes: readonly string[],
integrations: AgentIntegrationStatusEntry[],
status: ConfirmedStatus,
source: 'server' | 'config',
): void {
const fromServer = source === 'server';
const previousStatuses = { ...state.statuses.value };
const previousRuntimeErrors = { ...state.runtimeErrors.value };
// An answer of `disconnected` was about a channel that did not exist then. If
// configuration has one now, the seed is the fresher account of it.
const answeredFor = (type: string) =>
state.serverConfirmed.value.has(type) &&
previousStatuses[type] !== 'disconnected' &&
previousStatuses[type] !== 'unknown';
for (const type of integrationTypes) {
state.statuses.value[type] = 'disconnected';
state.connectedCredentials.value[type] = '';
state.integrationSettings.value[type] = undefined;
state.runtimeErrors.value[type] = '';
}
for (const integration of integrations) {
state.statuses.value[integration.type] = status;
// Only `starting` is the seed guessing at runtime state, and only a guess
// has to give way. `configured` is the seed saying the agent is unpublished,
// which is configuration's own fact and outranks any earlier answer — the
// channels of an unpublished agent are not running, whatever they were doing
// before it was unpublished.
const keepServerAnswer =
!fromServer && integration.status === 'starting' && answeredFor(integration.type);
state.statuses.value[integration.type] = keepServerAnswer
? previousStatuses[integration.type]
: integration.status;
state.connectedCredentials.value[integration.type] =
typeof integration.credentialId === 'string' ? integration.credentialId : '';
state.integrationSettings.value[integration.type] = integration.settings;
state.runtimeErrors.value[integration.type] = keepServerAnswer
? (previousRuntimeErrors[integration.type] ?? '')
: (integration.errorMessage ?? '');
}
for (const type of integrationTypes) {
if (fromServer) {
state.serverConfirmed.value.add(type);
continue;
}
// A channel configuration no longer has is gone whatever the server last
// said about it, so its answer goes with it.
if (!integrations.some((integration) => integration.type === type)) {
state.serverConfirmed.value.delete(type);
}
}
}
@@ -83,9 +152,9 @@ export function syncAgentIntegrationStatusCache(
agentId: string,
integrationTypes: readonly string[],
integrations: AgentIntegrationStatusEntry[],
status: ConfirmedStatus,
): void {
applyStatus(getOrCreate(projectId, agentId), integrationTypes, integrations, status);
// Seeded from the agent's own configuration, not from the status endpoint.
applyStatus(getOrCreate(projectId, agentId), integrationTypes, integrations, 'config');
}
export function useAgentIntegrationStatus(projectId: string, agentId: string) {
@@ -102,13 +171,14 @@ export function useAgentIntegrationStatus(projectId: string, agentId: string) {
state.fetchInFlight = (async () => {
try {
const result = await getIntegrationStatus(rootStore.restApiContext, projectId, agentId);
applyStatus(state, integrationTypes, result.integrations ?? [], result.status);
applyStatus(state, integrationTypes, result.integrations ?? [], 'server');
} catch {
// Mark only types we don't already have a confirmed answer for as
// `unknown` — a transient network/API failure shouldn't claim that
// a confirmed configured or connected integration is now disconnected.
// Mark only types the server hasn't answered for as `unknown` — a
// transient network failure shouldn't claim that a channel the server
// already told us about is now disconnected, and shouldn't dress up a
// locally-seeded guess as an answer either.
for (const type of integrationTypes) {
if (!['configured', 'connected'].includes(state.statuses.value[type])) {
if (!state.serverConfirmed.value.has(type)) {
state.statuses.value[type] = 'unknown';
}
}
@@ -124,7 +194,7 @@ export function useAgentIntegrationStatus(projectId: string, agentId: string) {
credId: string,
settings?: AgentIntegrationSettings,
options?: ConnectIntegrationOptions,
): Promise<Pick<AgentIntegrationStatusResponse, 'status'>> {
): Promise<AgentIntegrationConnectResponse> {
state.loadingMap.value[type] = true;
state.errorMessages.value[type] = '';
state.errorIsConflict.value[type] = false;
@@ -143,6 +213,11 @@ export function useAgentIntegrationStatus(projectId: string, agentId: string) {
state.statuses.value[type] = result.status;
state.connectedCredentials.value[type] = credId;
state.integrationSettings.value[type] = settings;
// The channel just started, so whatever it failed with before is history.
state.runtimeErrors.value[type] = '';
// The server answered for this channel, even though it was a mutation
// rather than a status read — a later failed refetch must not downgrade it.
state.serverConfirmed.value.add(type);
return result;
} catch (e: unknown) {
const msg =
@@ -177,6 +252,8 @@ export function useAgentIntegrationStatus(projectId: string, agentId: string) {
state.statuses.value[type] = 'disconnected';
state.connectedCredentials.value[type] = '';
state.integrationSettings.value[type] = undefined;
state.runtimeErrors.value[type] = '';
state.serverConfirmed.value.add(type);
return result;
} finally {
state.loadingMap.value[type] = false;
@@ -187,8 +264,21 @@ export function useAgentIntegrationStatus(projectId: string, agentId: string) {
return state.statuses.value[type] === 'connected';
}
/** Set up, whether or not it is currently running. */
function isConfigured(type: string): boolean {
return ['configured', 'connected'].includes(state.statuses.value[type]);
return (['configured', 'starting', 'connected', 'error'] as Status[]).includes(
state.statuses.value[type],
);
}
/** Should be running and is not — the last startup attempt failed. */
function hasRuntimeError(type: string): boolean {
return state.statuses.value[type] === 'error';
}
/** Should be running, with no attempt reported back yet. */
function isStarting(type: string): boolean {
return state.statuses.value[type] === 'starting';
}
function clearError(type: string): void {
@@ -203,11 +293,14 @@ export function useAgentIntegrationStatus(projectId: string, agentId: string) {
loadingMap: state.loadingMap,
errorMessages: state.errorMessages,
errorIsConflict: state.errorIsConflict,
runtimeErrors: state.runtimeErrors,
fetchStatus,
connect,
disconnect,
clearError,
isConnected,
isConfigured,
hasRuntimeError,
isStarting,
};
}