fix(core): Show Agent references in credential usage (#35409)

This commit is contained in:
Michael Drury 2026-08-05 16:54:00 +01:00 committed by GitHub
parent 4103f78638
commit a74c6fd11f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1198 additions and 27 deletions

View File

@ -11,6 +11,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [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 |
| [public.agent_credential_dependency](public.agent_credential_dependency.md) | 3 | | BASE TABLE |
| [public.agent_eval_dataset](public.agent_eval_dataset.md) | 10 | | BASE TABLE |
| [public.agent_eval_rating](public.agent_eval_rating.md) | 8 | | BASE TABLE |
| [public.agent_eval_result](public.agent_eval_result.md) | 15 | | BASE TABLE |
@ -158,6 +159,8 @@ erDiagram
"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"
"public.agent_credential_dependency" }o--|| "public.credentials_entity" : "FOREIGN KEY (#quot;credentialId#quot;) REFERENCES credentials_entity(id) ON DELETE CASCADE"
"public.agent_credential_dependency" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_eval_dataset" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.agent_eval_dataset" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_eval_rating" }o--o| "public.user" : "FOREIGN KEY (#quot;ratedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
@ -361,6 +364,11 @@ erDiagram
text state
timestamp_3__with_time_zone updatedAt
}
"public.agent_credential_dependency" {
varchar_36_ agentId FK
timestamp_3__with_time_zone createdAt
varchar_36_ credentialId FK
}
"public.agent_eval_dataset" {
varchar_36_ agentId FK
json columnMapping

View File

@ -0,0 +1,75 @@
# public.agent_credential_dependency
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| agentId | varchar(36) | | false | | [public.agents](public.agents.md) | |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| credentialId | varchar(36) | | false | | [public.credentials_entity](public.credentials_entity.md) | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| FK_6a6948884969cb4204a1975578b | FOREIGN KEY | FOREIGN KEY ("agentId") REFERENCES agents(id) ON DELETE CASCADE |
| FK_fec7ee37062350d6a4a2979327d | FOREIGN KEY | FOREIGN KEY ("credentialId") REFERENCES credentials_entity(id) ON DELETE CASCADE |
| PK_546f5d9cfaf5ea78efcc6f5d0a2 | PRIMARY KEY | PRIMARY KEY ("agentId", "credentialId") |
| agent_credential_dependency_agentId_not_null | n | NOT NULL "agentId" |
| agent_credential_dependency_createdAt_not_null | n | NOT NULL "createdAt" |
| agent_credential_dependency_credentialId_not_null | n | NOT NULL "credentialId" |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_fec7ee37062350d6a4a2979327 | CREATE INDEX "IDX_fec7ee37062350d6a4a2979327" ON public.agent_credential_dependency USING btree ("credentialId") |
| PK_546f5d9cfaf5ea78efcc6f5d0a2 | CREATE UNIQUE INDEX "PK_546f5d9cfaf5ea78efcc6f5d0a2" ON public.agent_credential_dependency USING btree ("agentId", "credentialId") |
## Relations
```mermaid
erDiagram
"public.agent_credential_dependency" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_credential_dependency" }o--|| "public.credentials_entity" : "FOREIGN KEY (#quot;credentialId#quot;) REFERENCES credentials_entity(id) ON DELETE CASCADE"
"public.agent_credential_dependency" {
varchar_36_ agentId FK
timestamp_3__with_time_zone createdAt
varchar_36_ credentialId FK
}
"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
json schema
timestamp_3__with_time_zone setupCompletedAt
json skills
json tools
timestamp_3__with_time_zone updatedAt
varchar_36_ versionId
}
"public.credentials_entity" {
timestamp_3__with_time_zone createdAt
text data
varchar_36_ id
boolean isGlobal
boolean isManaged
boolean isResolvable
varchar_128_ name
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_128_ type
timestamp_3__with_time_zone updatedAt
varchar_16_ usageScope
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)

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_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_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) | |
@ -52,6 +52,7 @@ erDiagram
"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"
"public.agent_credential_dependency" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_eval_dataset" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_execution_threads" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_files" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
@ -123,6 +124,11 @@ erDiagram
text state
timestamp_3__with_time_zone updatedAt
}
"public.agent_credential_dependency" {
varchar_36_ agentId FK
timestamp_3__with_time_zone createdAt
varchar_36_ credentialId FK
}
"public.agent_eval_dataset" {
varchar_36_ agentId FK
json columnMapping

View File

@ -6,7 +6,7 @@
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| data | text | | false | | | |
| id | varchar(36) | | false | [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.credential_dependency](public.credential_dependency.md) [public.dynamic_credential_entry](public.dynamic_credential_entry.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_credential_assignment](public.instance_credential_assignment.md) [public.shared_credentials](public.shared_credentials.md) | | |
| id | varchar(36) | | false | [public.agent_credential_dependency](public.agent_credential_dependency.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.credential_dependency](public.credential_dependency.md) [public.dynamic_credential_entry](public.dynamic_credential_entry.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_credential_assignment](public.instance_credential_assignment.md) [public.shared_credentials](public.shared_credentials.md) | | |
| isGlobal | boolean | false | false | | | |
| isManaged | boolean | false | false | | | |
| isResolvable | boolean | false | false | | | |
@ -50,6 +50,7 @@
```mermaid
erDiagram
"public.agent_credential_dependency" }o--|| "public.credentials_entity" : "FOREIGN KEY (#quot;credentialId#quot;) REFERENCES credentials_entity(id) ON DELETE CASCADE"
"public.chat_hub_agents" }o--o| "public.credentials_entity" : "FOREIGN KEY (#quot;credentialId#quot;) REFERENCES credentials_entity(id) ON DELETE SET NULL"
"public.chat_hub_sessions" }o--o| "public.credentials_entity" : "FOREIGN KEY (#quot;credentialId#quot;) REFERENCES credentials_entity(id) ON DELETE SET NULL"
"public.credential_dependency" }o--|| "public.credentials_entity" : "FOREIGN KEY (#quot;credentialId#quot;) REFERENCES credentials_entity(id) ON DELETE CASCADE"
@ -74,6 +75,11 @@ erDiagram
timestamp_3__with_time_zone updatedAt
varchar_16_ usageScope
}
"public.agent_credential_dependency" {
varchar_36_ agentId FK
timestamp_3__with_time_zone createdAt
varchar_36_ credentialId FK
}
"public.chat_hub_agents" {
timestamp_3__with_time_zone createdAt
varchar_36_ credentialId FK

View File

@ -11,6 +11,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [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 |
| [agent_credential_dependency](agent_credential_dependency.md) | 3 | | table |
| [agent_eval_dataset](agent_eval_dataset.md) | 10 | | table |
| [agent_eval_rating](agent_eval_rating.md) | 8 | | table |
| [agent_eval_result](agent_eval_result.md) | 15 | | table |
@ -141,6 +142,8 @@ erDiagram
"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"
"agent_credential_dependency" |o--|| "credentials_entity" : "FOREIGN KEY (credentialId) REFERENCES credentials_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_credential_dependency" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_eval_dataset" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agent_eval_dataset" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_eval_rating" }o--o| "user" : "FOREIGN KEY (ratedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
@ -348,6 +351,11 @@ erDiagram
TEXT state
datetime_3_ updatedAt
}
"agent_credential_dependency" {
varchar_36_ agentId PK
datetime_3_ createdAt
varchar_36_ credentialId PK
}
"agent_eval_dataset" {
varchar_36_ agentId FK
TEXT columnMapping

View File

@ -0,0 +1,85 @@
# agent_credential_dependency
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "agent_credential_dependency" ("agentId" varchar(36) NOT NULL, "credentialId" varchar(36) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_6a6948884969cb4204a1975578b" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE, CONSTRAINT "FK_fec7ee37062350d6a4a2979327d" FOREIGN KEY ("credentialId") REFERENCES "credentials_entity" ("id") ON DELETE CASCADE, PRIMARY KEY ("agentId", "credentialId"))
```
</details>
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| agentId | varchar(36) | | false | | [agents](agents.md) | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| credentialId | varchar(36) | | false | | [credentials_entity](credentials_entity.md) | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (credentialId) REFERENCES credentials_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| - (Foreign key ID: 1) | 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) |
| sqlite_autoindex_agent_credential_dependency_1 | PRIMARY KEY | PRIMARY KEY (agentId, credentialId) |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_fec7ee37062350d6a4a2979327 | CREATE INDEX "IDX_fec7ee37062350d6a4a2979327" ON "agent_credential_dependency" ("credentialId") |
| sqlite_autoindex_agent_credential_dependency_1 | PRIMARY KEY (agentId, credentialId) |
## Relations
```mermaid
erDiagram
"agent_credential_dependency" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_credential_dependency" |o--|| "credentials_entity" : "FOREIGN KEY (credentialId) REFERENCES credentials_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_credential_dependency" {
varchar_36_ agentId PK
datetime_3_ createdAt
varchar_36_ credentialId PK
}
"agents" {
varchar_36_ activeVersionId FK
boolean availableInMCP
datetime_3_ createdAt
varchar_36_ id PK
TEXT integrations
varchar_128_ name
varchar_255_ projectId FK
TEXT schema
datetime_3_ setupCompletedAt
TEXT skills
TEXT tools
datetime_3_ updatedAt
varchar_36_ versionId
}
"credentials_entity" {
datetime_3_ createdAt
TEXT data
varchar_36_ id PK
boolean isGlobal
boolean isManaged
boolean isResolvable
varchar_128_ name
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_32_ type
datetime_3_ updatedAt
VARCHAR_16_ usageScope
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)

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_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_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) | |
@ -55,6 +55,7 @@ erDiagram
"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"
"agent_credential_dependency" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_eval_dataset" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_execution_threads" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_files" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@ -126,6 +127,11 @@ erDiagram
TEXT state
datetime_3_ updatedAt
}
"agent_credential_dependency" {
varchar_36_ agentId PK
datetime_3_ createdAt
varchar_36_ credentialId PK
}
"agent_eval_dataset" {
varchar_36_ agentId FK
TEXT columnMapping

View File

@ -17,7 +17,7 @@ CREATE TABLE "credentials_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name"
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| data | TEXT | | false | | | |
| id | varchar(36) | | false | [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [credential_dependency](credential_dependency.md) [dynamic_credential_entry](dynamic_credential_entry.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_credential_assignment](instance_credential_assignment.md) [shared_credentials](shared_credentials.md) | | |
| id | varchar(36) | | false | [agent_credential_dependency](agent_credential_dependency.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [credential_dependency](credential_dependency.md) [dynamic_credential_entry](dynamic_credential_entry.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_credential_assignment](instance_credential_assignment.md) [shared_credentials](shared_credentials.md) | | |
| isGlobal | boolean | 0 | false | | | |
| isManaged | boolean | 0 | false | | | |
| isResolvable | boolean | false | false | | | |
@ -50,6 +50,7 @@ CREATE TABLE "credentials_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name"
```mermaid
erDiagram
"agent_credential_dependency" |o--|| "credentials_entity" : "FOREIGN KEY (credentialId) REFERENCES credentials_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"chat_hub_agents" }o--o| "credentials_entity" : "FOREIGN KEY (credentialId) REFERENCES credentials_entity (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"chat_hub_sessions" }o--o| "credentials_entity" : "FOREIGN KEY (credentialId) REFERENCES credentials_entity (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"credential_dependency" }o--|| "credentials_entity" : "FOREIGN KEY (credentialId) REFERENCES credentials_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@ -74,6 +75,11 @@ erDiagram
datetime_3_ updatedAt
VARCHAR_16_ usageScope
}
"agent_credential_dependency" {
varchar_36_ agentId PK
datetime_3_ createdAt
varchar_36_ credentialId PK
}
"chat_hub_agents" {
datetime_3_ createdAt
varchar_36_ credentialId FK

View File

@ -1,4 +1,5 @@
export interface DependencyTypeCounts {
agentUsage: number;
credentialId: number;
dataTableId: number;
errorWorkflow: number;

View File

@ -1,4 +1,5 @@
export type DependencyType =
| 'agentUsage'
| 'credentialId'
| 'dataTableId'
| 'errorWorkflow'
@ -12,7 +13,7 @@ export interface ResolvedDependency {
type: DependencyType;
id: string;
name: string;
/** Project ID — included for data tables so the frontend can build a direct link */
/** Project ID — included for agents and data tables so the frontend can build a direct link */
projectId?: string;
}

View File

@ -0,0 +1,26 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateAgentCredentialDependencyTable1785828155092 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable('agent_credential_dependency')
.withColumns(
column('agentId').varchar(36).primary,
column('credentialId').varchar(36).primary,
)
.withForeignKey('agentId', {
tableName: 'agents',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('credentialId', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn(['credentialId']).withCreatedAt;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('agent_credential_dependency');
}
}

View File

@ -238,6 +238,7 @@ import { AddMisfirePolicyToScheduler1785247194307 } from '../common/178524719430
import { CreateAgentChatAttachmentsTable1785255306000 } from '../common/1785255306000-CreateAgentChatAttachmentsTable';
import { AddSetupCompletedAtToAgents1785500832626 } from '../common/1785500832626-AddSetupCompletedAtToAgents';
import { AddAgentExecutionRuntimeState1785828155091 } from '../common/1785828155091-AddAgentExecutionRuntimeState';
import { CreateAgentCredentialDependencyTable1785828155092 } from '../common/1785828155092-CreateAgentCredentialDependencyTable';
import type { Migration } from '../migration-types';
export const postgresMigrations: Migration[] = [
@ -481,4 +482,5 @@ export const postgresMigrations: Migration[] = [
CreateAgentChatAttachmentsTable1785255306000,
AddSetupCompletedAtToAgents1785500832626,
AddAgentExecutionRuntimeState1785828155091,
CreateAgentCredentialDependencyTable1785828155092,
];

View File

@ -230,6 +230,7 @@ import { AddAgentFileStorageColumns1785186578138 } from '../common/1785186578138
import { CrashStaleEnqueuedExecutions1785247194306 } from '../common/1785247194306-CrashStaleEnqueuedExecutions';
import { CreateAgentChatAttachmentsTable1785255306000 } from '../common/1785255306000-CreateAgentChatAttachmentsTable';
import { AddAgentExecutionRuntimeState1785828155091 } from '../common/1785828155091-AddAgentExecutionRuntimeState';
import { CreateAgentCredentialDependencyTable1785828155092 } from '../common/1785828155092-CreateAgentCredentialDependencyTable';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@ -463,6 +464,7 @@ const sqliteMigrations: Migration[] = [
CreateAgentChatAttachmentsTable1785255306000,
AddSetupCompletedAtToAgents1785500832626,
AddAgentExecutionRuntimeState1785828155091,
CreateAgentCredentialDependencyTable1785828155092,
];
export { sqliteMigrations };

View File

@ -1057,6 +1057,10 @@ export type RelayEventMap = {
// #endregion
// region Agents
'agent-saved': {
agentId: string;
};
'agent-deleted': {
agentId: string;
projectId: string;

View File

@ -6,6 +6,7 @@ import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { mock } from 'vitest-mock-extended';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { EventService } from '@/events/event.service';
import type { Telemetry } from '@/telemetry';
@ -58,6 +59,7 @@ function makeService() {
const runtimeCacheService = mock<AgentRuntimeCacheService>();
const credentialsService = mock<CredentialsService>();
const workflowRepository = mock<WorkflowRepository>();
const eventService = mock<EventService>();
const agentValidationService = mock<AgentValidationService>();
const telemetry = mock<Telemetry>();
@ -87,6 +89,7 @@ function makeService() {
runtimeCacheService,
credentialsService,
workflowRepository,
eventService,
new AgentSetupCompletionService(agentValidationService, telemetry, agentRepository),
new AgentModificationTelemetryService(telemetry),
);
@ -99,6 +102,7 @@ function makeService() {
runtimeCacheService,
credentialsService,
workflowRepository,
eventService,
agentValidationService,
telemetry,
};
@ -215,7 +219,7 @@ describe('AgentConfigService', () => {
it('persists an explicit web-search disable and clears native provider tools', async () => {
// Regression: previously the disable was stripped on write and resurrected
// on read, so the config hash never changed and the builder looped.
const { service, agentRepository } = makeService();
const { service, agentRepository, eventService } = makeService();
const agent = makeAgent({
schema: {
...baseConfig,
@ -244,6 +248,7 @@ describe('AgentConfigService', () => {
// layer's freshness hash actually changes.
expect(result.config?.config?.webSearch).toEqual({ enabled: false });
expect(result.config?.providerTools).toEqual({});
expect(eventService.emit).toHaveBeenCalledWith('agent-saved', { agentId });
});
it('preserves omitted stored fields but clears explicitly empty integrations', async () => {

View File

@ -0,0 +1,171 @@
/* eslint-disable @typescript-eslint/unbound-method */
import type { CredentialsEntity, OperationContext, TransactionRunner } from '@n8n/db';
import type { DataSource, SelectQueryBuilder } from '@n8n/typeorm';
import { mock, type MockProxy } from 'vitest-mock-extended';
import { mockEntityManager } from '@test/mocking';
import { AgentCredentialDependency } from '../entities/agent-credential-dependency.entity';
import type { AgentHistory } from '../entities/agent-history.entity';
import { Agent } from '../entities/agent.entity';
import { AgentCredentialDependencyRepository } from '../repositories/agent-credential-dependency.repository';
const entityManager = mockEntityManager(AgentCredentialDependency);
const mockDataSource = { manager: entityManager };
const makeAgent = (): Agent =>
({
id: 'agent-1',
schema: {
name: 'Support Agent',
model: 'openai/gpt-4.1-mini',
instructions: 'Help the user',
credential: 'credential-draft',
memory: {
enabled: true,
storage: 'n8n',
episodicMemory: { enabled: true, credential: 'credential-shared' },
},
},
integrations: [{ type: 'slack', credentialId: 'credential-integration' }],
activeVersionId: 'published-version-1',
}) as Agent;
const makePublishedVersion = (): AgentHistory =>
({
versionId: 'published-version-1',
agentId: 'agent-1',
schema: {
name: 'Support Agent',
model: 'openai/gpt-4.1-mini',
instructions: 'Help the user',
credential: 'credential-published',
tools: [
{
type: 'node',
name: 'OpenAI tool',
node: {
nodeType: 'n8n-nodes-base.openAiTool',
nodeTypeVersion: 1,
nodeParameters: {},
credentials: {
openAiApi: { id: 'credential-shared', name: 'OpenAI account' },
},
},
},
],
},
}) as unknown as AgentHistory;
describe('AgentCredentialDependencyRepository', () => {
let repository: AgentCredentialDependencyRepository;
let txRunner: MockProxy<TransactionRunner>;
let credentialQuery: MockProxy<SelectQueryBuilder<CredentialsEntity>>;
beforeEach(() => {
vi.clearAllMocks();
Object.assign(entityManager.connection, { options: { type: 'sqlite' } });
txRunner = mock<TransactionRunner>();
txRunner.run.mockImplementation(
async <T>(ctx: OperationContext, fn: (ctx: OperationContext) => Promise<T>) => await fn(ctx),
);
credentialQuery = mock<SelectQueryBuilder<CredentialsEntity>>();
credentialQuery.select.mockReturnValue(credentialQuery);
credentialQuery.where.mockReturnValue(credentialQuery);
entityManager.createQueryBuilder.mockReturnValue(credentialQuery);
repository = new AgentCredentialDependencyRepository(
mockDataSource as unknown as DataSource,
txRunner,
);
});
it('atomically rebuilds the union of draft and published credential references', async () => {
const agent = makeAgent();
const publishedVersion = makePublishedVersion();
entityManager.findOne.mockImplementation(async (entity) => {
if (entity === Agent) return agent;
return publishedVersion;
});
credentialQuery.getMany.mockResolvedValue([
mock<CredentialsEntity>({ id: 'credential-draft' }),
mock<CredentialsEntity>({ id: 'credential-shared' }),
mock<CredentialsEntity>({ id: 'credential-integration' }),
mock<CredentialsEntity>({ id: 'credential-published' }),
]);
await repository.refreshForAgent(agent.id);
expect(entityManager.delete).toHaveBeenCalledWith(AgentCredentialDependency, {
agentId: agent.id,
});
expect(entityManager.insert).toHaveBeenCalledWith(AgentCredentialDependency, [
{
agentId: agent.id,
credentialId: 'credential-draft',
},
{
agentId: agent.id,
credentialId: 'credential-shared',
},
{
agentId: agent.id,
credentialId: 'credential-integration',
},
{
agentId: agent.id,
credentialId: 'credential-published',
},
]);
});
it('filters stale credential ids without failing the replacement', async () => {
const agent = makeAgent();
agent.activeVersionId = null;
entityManager.findOne.mockResolvedValue(agent);
credentialQuery.getMany.mockResolvedValue([
mock<CredentialsEntity>({ id: 'credential-draft' }),
]);
await repository.refreshForAgent(agent.id);
expect(entityManager.insert).toHaveBeenCalledWith(AgentCredentialDependency, [
{
agentId: agent.id,
credentialId: 'credential-draft',
},
]);
});
it('leaves the replacement empty when the agent no longer exists', async () => {
entityManager.findOne.mockResolvedValue(null);
await repository.refreshForAgent('deleted-agent');
expect(entityManager.delete).toHaveBeenCalledWith(AgentCredentialDependency, {
agentId: 'deleted-agent',
});
expect(entityManager.createQueryBuilder).not.toHaveBeenCalled();
expect(entityManager.insert).not.toHaveBeenCalled();
});
it('locks the current agent and referenced credentials before replacing rows on Postgres', async () => {
Object.assign(entityManager.connection, { options: { type: 'postgres' } });
const agent = makeAgent();
agent.activeVersionId = null;
entityManager.findOne.mockResolvedValue(agent);
credentialQuery.getMany.mockResolvedValue([
mock<CredentialsEntity>({ id: 'credential-draft' }),
]);
await repository.refreshForAgent(agent.id);
expect(entityManager.findOne).toHaveBeenCalledWith(Agent, {
where: { id: agent.id },
lock: { mode: 'pessimistic_write' },
});
expect(credentialQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
expect(credentialQuery.getMany.mock.invocationCallOrder[0]).toBeLessThan(
entityManager.delete.mock.invocationCallOrder[0],
);
});
});

View File

@ -0,0 +1,67 @@
/* eslint-disable @typescript-eslint/unbound-method */
import type { Logger } from '@n8n/backend-common';
import type { WorkflowsConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
import { AgentCredentialIndexService } from '../agent-credential-index.service';
import type { AgentCredentialDependencyRepository } from '../repositories/agent-credential-dependency.repository';
import type { AgentRepository } from '../repositories/agent.repository';
function makeService(batchSize = 2) {
const dependencyRepository = mock<AgentCredentialDependencyRepository>();
const agentRepository = mock<AgentRepository>();
const workflowsConfig = mock<WorkflowsConfig>({ indexingBatchSize: batchSize });
const logger = mock<Logger>();
logger.scoped.mockReturnValue(logger);
const service = new AgentCredentialIndexService(
dependencyRepository,
agentRepository,
logger,
workflowsConfig,
);
return { service, dependencyRepository, agentRepository, logger };
}
describe('AgentCredentialIndexService', () => {
it('refreshes both sources from current persisted Agent state', async () => {
const { service, dependencyRepository } = makeService();
await service.refresh('agent-1');
expect(dependencyRepository.refreshForAgent).toHaveBeenCalledWith('agent-1');
});
it('removes all rows as an idempotent fallback when an agent is deleted', async () => {
const { service, dependencyRepository } = makeService();
await service.remove('agent-1');
expect(dependencyRepository.removeForAgent).toHaveBeenCalledWith('agent-1');
});
it('rebuilds every agent in batches and continues after an individual failure', async () => {
const { service, dependencyRepository, agentRepository, logger } = makeService(2);
agentRepository.findCredentialIndexAgentIdsBatch
.mockResolvedValueOnce([{ id: 'agent-a' }, { id: 'agent-b' }])
.mockResolvedValueOnce([{ id: 'agent-c' }]);
dependencyRepository.refreshForAgent.mockImplementation(async (agentId) => {
if (agentId === 'agent-b') throw new Error('transient failure');
});
await service.buildIndex();
expect(agentRepository.findCredentialIndexAgentIdsBatch).toHaveBeenNthCalledWith(1, null, 2);
expect(agentRepository.findCredentialIndexAgentIdsBatch).toHaveBeenNthCalledWith(
2,
'agent-b',
2,
);
expect(dependencyRepository.refreshForAgent).toHaveBeenCalledTimes(3);
expect(dependencyRepository.refreshForAgent).toHaveBeenCalledWith('agent-c');
expect(logger.error).toHaveBeenCalledWith('Failed to index agent credential dependencies', {
agentId: 'agent-b',
error: expect.any(Error),
});
});
});

View File

@ -5,6 +5,7 @@ import { UserError } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { EventService } from '@/events/event.service';
import type { Telemetry } from '@/telemetry';
import { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service';
@ -52,6 +53,7 @@ function makeService() {
const chatIntegrationService = mock<ChatIntegrationService>();
const runtimeCacheService = mock<AgentRuntimeCacheService>();
const chatIntegrationRegistry = mock<ChatIntegrationRegistry>();
const eventService = mock<EventService>();
const telemetry = mock<Telemetry>();
const credentialsService = mock<CredentialsService>();
const setupCompletionService = mock<AgentSetupCompletionService>();
@ -65,6 +67,7 @@ function makeService() {
chatIntegrationService,
runtimeCacheService,
chatIntegrationRegistry,
eventService,
new AgentModificationTelemetryService(telemetry),
credentialsService,
setupCompletionService,
@ -73,6 +76,7 @@ function makeService() {
chatIntegrationService,
runtimeCacheService,
chatIntegrationRegistry,
eventService,
telemetry,
credentialsService,
setupCompletionService,
@ -121,7 +125,8 @@ describe('AgentIntegrationPersistenceService', () => {
});
it('appends a credential integration to an empty list and invalidates the runtime cache', async () => {
const { service, agentRepository, chatIntegrationService, runtimeCacheService } = makeService();
const { service, agentRepository, chatIntegrationService, runtimeCacheService, eventService } =
makeService();
const agent = makeAgent();
await service.saveCredentialIntegration(
@ -134,6 +139,7 @@ describe('AgentIntegrationPersistenceService', () => {
expect(agent.versionId).not.toBe(agent.activeVersionId);
expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId);
expect(agentRepository.save).toHaveBeenCalledWith(agent);
expect(eventService.emit).toHaveBeenCalledWith('agent-saved', { agentId });
expect(chatIntegrationService.broadcastIntegrationChange).toHaveBeenCalledWith(
agentId,
{ type: 'slack', credentialId: 'slack-1' },

View File

@ -6,6 +6,7 @@ import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { mock } from 'vitest-mock-extended';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { EventService } from '@/events/event.service';
import type { Telemetry } from '@/telemetry';
import type { AgentCustomToolsService } from '../agent-custom-tools.service';
@ -113,6 +114,7 @@ function makeService() {
const agentValidationService = mock<AgentValidationService>();
const credentialsService = mock<CredentialsService>();
const telemetry = mock<Telemetry>();
const eventService = mock<EventService>();
const { trx, taskRepo, transaction } = makeTransaction();
Object.defineProperty(agentRepository, 'manager', {
@ -152,6 +154,7 @@ function makeService() {
agentValidationService,
credentialsService,
telemetry,
eventService,
new AgentSetupCompletionService(agentValidationService, telemetry, agentRepository),
new AgentModificationTelemetryService(telemetry),
);
@ -170,6 +173,7 @@ function makeService() {
agentValidationService,
credentialsService,
telemetry,
eventService,
trx,
taskRepo,
};
@ -262,6 +266,7 @@ describe('AgentPublishService', () => {
chatIntegrationService,
agentValidationService,
telemetry,
eventService,
trx,
} = makeService();
const configuredTools = { tool: { descriptor: { name: 'tool' } } };
@ -328,6 +333,7 @@ describe('AgentPublishService', () => {
);
expect(agent.activeVersionId).toBe(versionId);
expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId);
expect(eventService.emit).toHaveBeenCalledWith('agent-saved', { agentId });
expect(chatIntegrationService.syncToConfig).toHaveBeenCalledWith(agent, [], integrations);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.AGENTS.BUILDER_PUBLISHED_AGENT,

View File

@ -254,6 +254,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
runtimeCacheService,
credentialsService,
mock<WorkflowRepository>(),
mock<EventService>(),
mock<AgentSetupCompletionService>(),
modificationTelemetry,
);
@ -278,6 +279,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
chatIntegrationService,
runtimeCacheService,
chatIntegrationRegistry,
mock<EventService>(),
modificationTelemetry,
credentialsService,
mock<AgentSetupCompletionService>(),
@ -303,6 +305,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
agentValidationService,
credentialsService,
telemetry,
mock<EventService>(),
mock<AgentSetupCompletionService>(),
mock<AgentModificationTelemetryService>(),
);

View File

@ -14,6 +14,7 @@ import { UserError } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
import {
AgentModificationTelemetryService,
@ -46,6 +47,7 @@ export class AgentConfigService {
private readonly runtimeCacheService: AgentRuntimeCacheService,
private readonly credentialsService: CredentialsService,
private readonly workflowRepository: WorkflowRepository,
private readonly eventService: EventService,
private readonly setupCompletionService: AgentSetupCompletionService,
private readonly modificationTelemetry: AgentModificationTelemetryService,
) {}
@ -264,6 +266,7 @@ export class AgentConfigService {
);
const saved = await this.agentRepository.save(entity);
this.eventService.emit('agent-saved', { agentId });
this.logger.debug('Updated agent JSON config', { agentId, projectId });
this.modificationTelemetry.record({

View File

@ -0,0 +1,43 @@
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { EventService } from '@/events/event.service';
import { AgentCredentialIndexService } from './agent-credential-index.service';
@Service()
export class AgentCredentialIndexListener {
constructor(
private readonly logger: Logger,
private readonly eventService: EventService,
private readonly indexService: AgentCredentialIndexService,
) {
this.logger = this.logger.scoped('agents');
}
init(): void {
this.eventService.on('server-started', async () => {
await this.run('build credential dependency index', async () => {
await this.indexService.buildIndex();
});
});
this.eventService.on('agent-saved', async ({ agentId }) => {
await this.run('refresh credential dependency index', async () => {
await this.indexService.refresh(agentId);
});
});
this.eventService.on('agent-deleted', async ({ agentId }) => {
await this.run('remove credential dependency index entries', async () => {
await this.indexService.remove(agentId);
});
});
}
private async run(action: string, operation: () => Promise<void>): Promise<void> {
try {
await operation();
} catch (error) {
this.logger.error(`Failed to ${action}`, { error });
}
}
}

View File

@ -0,0 +1,55 @@
import { Logger } from '@n8n/backend-common';
import { WorkflowsConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { AgentCredentialDependencyRepository } from './repositories/agent-credential-dependency.repository';
import { AgentRepository } from './repositories/agent.repository';
@Service()
export class AgentCredentialIndexService {
private readonly batchSize: number;
private readonly logger: Logger;
constructor(
private readonly dependencyRepository: AgentCredentialDependencyRepository,
private readonly agentRepository: AgentRepository,
logger: Logger,
workflowsConfig: WorkflowsConfig,
) {
this.logger = logger.scoped('agents');
this.batchSize = workflowsConfig.indexingBatchSize;
}
async remove(agentId: string): Promise<void> {
await this.dependencyRepository.removeForAgent(agentId);
}
async refresh(agentId: string): Promise<void> {
await this.dependencyRepository.refreshForAgent(agentId);
}
async buildIndex(): Promise<void> {
let afterId: string | null = null;
while (true) {
const agents = await this.agentRepository.findCredentialIndexAgentIdsBatch(
afterId,
this.batchSize,
);
for (const { id: agentId } of agents) {
try {
await this.refresh(agentId);
} catch (error) {
this.logger.error('Failed to index agent credential dependencies', { agentId, error });
}
}
if (agents.length < this.batchSize) return;
const lastAgent = agents[agents.length - 1];
if (lastAgent === undefined) return;
afterId = lastAgent.id;
}
}
}

View File

@ -9,6 +9,7 @@ import { Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
import { EventService } from '@/events/event.service';
import {
AgentModificationTelemetryService,
@ -38,6 +39,7 @@ export class AgentIntegrationPersistenceService {
private readonly chatIntegrationService: ChatIntegrationService,
private readonly runtimeCacheService: AgentRuntimeCacheService,
private readonly chatIntegrationRegistry: ChatIntegrationRegistry,
private readonly eventService: EventService,
private readonly modificationTelemetry: AgentModificationTelemetryService,
private readonly credentialsService: CredentialsService,
private readonly setupCompletionService: AgentSetupCompletionService,
@ -117,6 +119,7 @@ export class AgentIntegrationPersistenceService {
context.user,
);
const result = await this.agentRepository.save(agent);
this.eventService.emit('agent-saved', { agentId: agent.id });
await emitSetupCompleted?.();
await this.recordIntegrationMutation(
result,
@ -159,6 +162,7 @@ export class AgentIntegrationPersistenceService {
markAgentDraftDirty(agent);
this.runtimeCacheService.clearRuntimes(agent.id);
const result = await this.agentRepository.save(agent);
this.eventService.emit('agent-saved', { agentId: agent.id });
await this.recordIntegrationMutation(
result,
previousSchema,

View File

@ -16,6 +16,7 @@ import { v4 as uuid } from 'uuid';
import { CredentialsService } from '@/credentials/credentials.service';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
import { getMissingSkillIds } from '@/modules/agents/utils/agent-missing-skill-ids';
import { Telemetry } from '@/telemetry';
@ -99,6 +100,7 @@ export class AgentPublishService {
private readonly agentValidationService: AgentValidationService,
private readonly credentialsService: CredentialsService,
private readonly telemetry: Telemetry,
private readonly eventService: EventService,
private readonly setupCompletionService: AgentSetupCompletionService,
private readonly modificationTelemetry: AgentModificationTelemetryService,
) {}
@ -174,6 +176,7 @@ export class AgentPublishService {
await trx.save(agent);
});
this.eventService.emit('agent-saved', { agentId });
this.runtimeCacheService.clearRuntimes(agentId);
@ -262,6 +265,7 @@ export class AgentPublishService {
await trx.save(agent);
});
this.eventService.emit('agent-saved', { agentId });
this.runtimeCacheService.clearRuntimes(agentId);
@ -414,6 +418,7 @@ export class AgentPublishService {
await trx.save(agent);
tasksChanged = await this.restoreTasksFromSnapshot(trx, agentId, activeVersion.versionId);
});
this.eventService.emit('agent-saved', { agentId });
this.runtimeCacheService.clearRuntimes(agentId);
await this.recordRevert(agent, projectId, user, modifiedBy, previousSchema, {
@ -465,6 +470,7 @@ export class AgentPublishService {
await trx.save(agent);
tasksChanged = await this.restoreTasksFromSnapshot(trx, agentId, target.versionId);
});
this.eventService.emit('agent-saved', { agentId });
this.runtimeCacheService.clearRuntimes(agentId);
await this.recordRevert(agent, projectId, user, modifiedBy, previousSchema, {

View File

@ -29,6 +29,9 @@ export class AgentsModule implements ModuleInterface {
const { AgentsService } = await import('./agents.service.js');
Container.get(AgentsService);
const { AgentCredentialIndexListener } = await import('./agent-credential-index.listener.js');
Container.get(AgentCredentialIndexListener).init();
const { AgentsBuilderSettingsService } = await import(
'./builder/agents-builder-settings.service.js'
);
@ -165,6 +168,9 @@ export class AgentsModule implements ModuleInterface {
const { AgentExecutionThread } = await import('./entities/agent-execution-thread.entity.js');
const { AgentExecution } = await import('./entities/agent-execution.entity.js');
const { AgentHistory } = await import('./entities/agent-history.entity.js');
const { AgentCredentialDependency } = await import(
'./entities/agent-credential-dependency.entity.js'
);
const { AgentTask } = await import('./entities/agent-task.entity.js');
const { AgentTaskRunLock } = await import('./entities/agent-task-run-lock.entity.js');
const { AgentTaskSnapshot } = await import('./entities/agent-task-snapshot.entity.js');
@ -198,6 +204,7 @@ export class AgentsModule implements ModuleInterface {
AgentExecutionThread,
AgentExecution,
AgentHistory,
AgentCredentialDependency,
AgentTask,
AgentTaskRunLock,
AgentTaskSnapshot,

View File

@ -0,0 +1,22 @@
import { type CredentialsEntity, WithCreatedAt } from '@n8n/db';
import { Entity, Index, JoinColumn, ManyToOne, PrimaryColumn, type Relation } from '@n8n/typeorm';
import type { Agent } from './agent.entity';
@Entity({ name: 'agent_credential_dependency' })
export class AgentCredentialDependency extends WithCreatedAt {
@PrimaryColumn({ type: 'varchar', length: 36 })
agentId: string;
@ManyToOne('Agent', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'agentId' })
agent: Relation<Agent>;
@Index()
@PrimaryColumn({ type: 'varchar', length: 36 })
credentialId: string;
@ManyToOne('CredentialsEntity', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'credentialId' })
credential: Relation<CredentialsEntity>;
}

View File

@ -0,0 +1,107 @@
import { BaseRepository, CredentialsEntity, TransactionRunner } from '@n8n/db';
import { Service } from '@n8n/di';
import { DataSource, In, type EntityManager } from '@n8n/typeorm';
import { AgentCredentialDependency } from '../entities/agent-credential-dependency.entity';
import { AgentHistory } from '../entities/agent-history.entity';
import { Agent } from '../entities/agent.entity';
import { extractAgentCredentialIds } from '../utils/extract-agent-credential-ids';
export type AgentCredentialDependencyReference = Pick<
AgentCredentialDependency,
'agentId' | 'credentialId'
>;
@Service()
export class AgentCredentialDependencyRepository extends BaseRepository<AgentCredentialDependency> {
constructor(
dataSource: DataSource,
private readonly txRunner: TransactionRunner,
) {
super(AgentCredentialDependency, dataSource.manager);
}
async refreshForAgent(agentId: string): Promise<void> {
await this.txRunner.run({}, async (ctx) => {
const manager = this.managerFor(ctx);
const isPostgres = manager.connection.options.type === 'postgres';
// Starting with a write serializes the authoritative re-read on SQLite.
if (!isPostgres) {
await manager.delete(AgentCredentialDependency, { agentId });
}
const agent = isPostgres
? await manager.findOne(Agent, {
where: { id: agentId },
lock: { mode: 'pessimistic_write' },
})
: await manager.findOne(Agent, { where: { id: agentId } });
if (agent === null) {
if (isPostgres) await manager.delete(AgentCredentialDependency, { agentId });
return;
}
const publishedVersion =
agent.activeVersionId === null
? null
: await manager.findOne(AgentHistory, {
where: { versionId: agent.activeVersionId, agentId },
});
const draftCredentialIds = extractAgentCredentialIds([agent.schema, agent.integrations]);
const publishedCredentialIds = extractAgentCredentialIds(publishedVersion?.schema);
const referencedCredentialIds = new Set([...draftCredentialIds, ...publishedCredentialIds]);
const existingCredentialIds = await this.findExistingCredentialIds(
manager,
referencedCredentialIds,
isPostgres,
);
if (isPostgres) {
await manager.delete(AgentCredentialDependency, { agentId });
}
const rows = [...referencedCredentialIds]
.filter((credentialId) => existingCredentialIds.has(credentialId))
.map((credentialId) => ({ agentId, credentialId }));
if (rows.length > 0) {
await manager.insert(AgentCredentialDependency, rows);
}
});
}
async removeForAgent(agentId: string): Promise<void> {
await this.managerFor({}).delete(AgentCredentialDependency, { agentId });
}
async findByCredentialIds(
credentialIds: string[],
): Promise<AgentCredentialDependencyReference[]> {
if (credentialIds.length === 0) return [];
return await this.find({
select: ['agentId', 'credentialId'],
where: { credentialId: In(credentialIds) },
});
}
private async findExistingCredentialIds(
manager: EntityManager,
credentialIds: ReadonlySet<string>,
lockForUpdate: boolean,
): Promise<Set<string>> {
if (credentialIds.size === 0) return new Set();
const query = manager
.createQueryBuilder(CredentialsEntity, 'credential')
.select(['credential.id'])
.where('credential.id IN (:...credentialIds)', { credentialIds: [...credentialIds] });
query.orderBy('credential.id', 'ASC');
if (lockForUpdate) query.setLock('pessimistic_write');
return new Set((await query.getMany()).map(({ id }) => id));
}
}

View File

@ -159,6 +159,33 @@ export class AgentRepository extends Repository<Agent> {
});
}
async findCredentialIndexAgentIdsBatch(
afterId: string | null,
batchSize: number,
): Promise<Array<Pick<Agent, 'id'>>> {
const query = this.createQueryBuilder('agent')
.select(['agent.id'])
.orderBy('agent.id', 'ASC')
.take(batchSize);
if (afterId !== null) {
query.where('agent.id > :afterId', { afterId });
}
return await query.getMany();
}
async findSummariesByIds(
ids: string[],
): Promise<Array<Pick<Agent, 'id' | 'name' | 'projectId'>>> {
if (ids.length === 0) return [];
return await this.find({
select: ['id', 'name', 'projectId'],
where: { id: In(ids) },
});
}
async findByIdInProjects(id: string, projectIds: string[]): Promise<Agent | null> {
if (projectIds.length === 0) return null;
return await this.findOne({

View File

@ -0,0 +1,105 @@
import { AI_GATEWAY_MANAGED_TAG, MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types';
import { extractAgentCredentialIds } from '../extract-agent-credential-ids';
describe('extractAgentCredentialIds', () => {
it('recursively extracts credential, credentialId, and credentials.*.id references', () => {
const result = extractAgentCredentialIds({
credential: 'main-model-credential',
memory: {
worker: { credential: 'memory-worker-credential' },
},
integrations: [{ credentialId: 'integration-credential' }],
tools: [
{
node: {
credentials: {
openAiApi: { id: 'node-tool-credential', name: 'OpenAI account' },
},
},
},
],
unrelated: { id: 'not-a-credential-reference' },
});
expect(result).toEqual(
new Set([
'main-model-credential',
'memory-worker-credential',
'integration-credential',
'node-tool-credential',
]),
);
});
it('deduplicates the same credential found through multiple reference forms', () => {
const result = extractAgentCredentialIds({
credential: 'shared-credential',
integrations: [{ credentialId: 'shared-credential' }],
tools: [
{
node: {
credentials: {
openAiApi: { id: 'shared-credential', name: 'OpenAI account' },
},
},
},
],
});
expect(result).toEqual(new Set(['shared-credential']));
});
it('ignores empty and managed credential references', () => {
const result = extractAgentCredentialIds({
credential: '',
memory: {
episodicMemory: { credential: MANAGED_CREDENTIAL_TOKEN },
worker: { credential: AI_GATEWAY_MANAGED_TAG },
},
integrations: [
{ credentialId: '' },
{ credentialId: MANAGED_CREDENTIAL_TOKEN },
{ credentialId: 'real-credential' },
],
tools: [
{
node: {
credentials: {
empty: { id: '', name: 'Empty' },
managed: { id: MANAGED_CREDENTIAL_TOKEN, name: 'Managed' },
gateway: { id: AI_GATEWAY_MANAGED_TAG, name: 'n8n Connect' },
},
},
},
],
});
expect(result).toEqual(new Set(['real-credential']));
});
it('lets callers extract draft and published snapshots separately and union them', () => {
const draftIds = extractAgentCredentialIds({
credential: 'draft-credential',
integrations: [{ credentialId: 'shared-credential' }],
});
const publishedIds = extractAgentCredentialIds({
credential: 'published-credential',
tools: [
{
node: {
credentials: {
openAiApi: { id: 'shared-credential', name: 'OpenAI account' },
},
},
},
],
});
expect(draftIds).toEqual(new Set(['draft-credential', 'shared-credential']));
expect(publishedIds).toEqual(new Set(['published-credential', 'shared-credential']));
expect(new Set([...draftIds, ...publishedIds])).toEqual(
new Set(['draft-credential', 'shared-credential', 'published-credential']),
);
});
});

View File

@ -0,0 +1,42 @@
import { AI_GATEWAY_MANAGED_TAG, MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types';
const MANAGED_CREDENTIAL_IDS = new Set<string>([MANAGED_CREDENTIAL_TOKEN, AI_GATEWAY_MANAGED_TAG]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function addCredentialId(value: unknown, credentialIds: Set<string>): void {
if (typeof value === 'string' && value !== '' && !MANAGED_CREDENTIAL_IDS.has(value)) {
credentialIds.add(value);
}
}
function collectCredentialIds(value: unknown, credentialIds: Set<string>): void {
if (Array.isArray(value)) {
for (const entry of value) collectCredentialIds(entry, credentialIds);
return;
}
if (!isRecord(value)) return;
for (const [key, entry] of Object.entries(value)) {
if (key === 'credential' || key === 'credentialId') {
addCredentialId(entry, credentialIds);
} else if (key === 'credentials' && isRecord(entry)) {
for (const credentialReference of Object.values(entry)) {
if (isRecord(credentialReference)) {
addCredentialId(credentialReference.id, credentialIds);
}
}
}
collectCredentialIds(entry, credentialIds);
}
}
export function extractAgentCredentialIds(value: unknown): Set<string> {
const credentialIds = new Set<string>();
collectCredentialIds(value, credentialIds);
return credentialIds;
}

View File

@ -25,6 +25,7 @@ vi.mock('@/modules/agents/json-config/mcp-client-factory', () => ({
}));
import { CredentialsService } from '@/credentials/credentials.service';
import type { EventService } from '@/events/event.service';
import { AgentConfigService } from '@/modules/agents/agent-config.service';
import { AgentCustomToolsService } from '@/modules/agents/agent-custom-tools.service';
import { AgentIntegrationPersistenceService } from '@/modules/agents/agent-integration-persistence.service';
@ -203,6 +204,7 @@ describe('McpAgentToolsService', () => {
runtimeCacheService,
localCredentialsService,
workflowRepository,
mock<EventService>(),
mock<AgentSetupCompletionService>(),
modificationTelemetry,
);

View File

@ -4,6 +4,7 @@ import type {
DependencyResourceType,
ResolvedDependency,
} from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import {
CredentialsRepository,
ProjectRelationRepository,
@ -16,11 +17,14 @@ import { hasGlobalScope } from '@n8n/permissions';
import { In } from '@n8n/typeorm';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { AgentCredentialDependencyRepository } from '@/modules/agents/repositories/agent-credential-dependency.repository';
import { AgentRepository } from '@/modules/agents/repositories/agent.repository';
import { DataTableRepository } from '@/modules/data-table/data-table.repository';
import { RoleService } from '@/services/role.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
interface RawDepMaps {
agentUsageMap: Map<string, Set<string>>;
credMap: Map<string, Set<string>>;
dtMap: Map<string, Set<string>>;
subMap: Map<string, Set<string>>;
@ -28,6 +32,7 @@ interface RawDepMaps {
errorWfMap: Map<string, Set<string>>;
errorWfParentMap: Map<string, Set<string>>;
allCredIds: Set<string>;
allAgentIds: Set<string>;
allWfIds: Set<string>;
allDtIds: Set<string>;
}
@ -43,6 +48,9 @@ export class WorkflowDependencyQueryService {
private readonly credentialsFinderService: CredentialsFinderService,
private readonly projectRelationRepository: ProjectRelationRepository,
private readonly roleService: RoleService,
private readonly agentDependencyRepository: AgentCredentialDependencyRepository,
private readonly agentRepository: AgentRepository,
private readonly moduleRegistry: ModuleRegistry,
) {}
async getDependencyCounts(
@ -58,6 +66,7 @@ export class WorkflowDependencyQueryService {
const result: DependencyCountsBatchResponse = {};
for (const id of accessibleInputIds) {
result[id] = {
agentUsage: maps.agentUsageMap.get(id)?.size ?? 0,
credentialId: maps.credMap.get(id)?.size ?? 0,
dataTableId: maps.dtMap.get(id)?.size ?? 0,
errorWorkflow: maps.errorWfMap.get(id)?.size ?? 0,
@ -81,16 +90,18 @@ export class WorkflowDependencyQueryService {
const { accessibleInputIds, maps } = loaded;
// Check user access for each dependency type
const [accessibleWfIds, accessibleCredIds, accessibleDtIds] = await Promise.all([
this.filterByAccess([...maps.allWfIds], 'workflow', user),
this.filterByAccess([...maps.allCredIds], 'credential', user),
this.filterByAccess([...maps.allDtIds], 'dataTable', user),
]);
const [accessibleWfIds, accessibleCredIds, accessibleDtIds, accessibleAgentProjectIds] =
await Promise.all([
this.filterByAccess([...maps.allWfIds], 'workflow', user),
this.filterByAccess([...maps.allCredIds], 'credential', user),
this.filterByAccess([...maps.allDtIds], 'dataTable', user),
maps.allAgentIds.size > 0 ? this.getAccessibleAgentProjectIds(user) : new Set<string>(),
]);
// Load all referenced resources (not just accessible ones) so that ids whose
// resource has been deleted — the index may still reference them — can be
// dropped instead of being reported as inaccessible.
const [credentials, workflows, dataTables] = await Promise.all([
const [credentials, workflows, dataTables, agents] = await Promise.all([
maps.allCredIds.size > 0
? this.credentialsRepository.find({
where: { id: In([...maps.allCredIds]), usageScope: 'project' },
@ -109,19 +120,34 @@ export class WorkflowDependencyQueryService {
select: ['id', 'name', 'projectId'],
})
: [],
maps.allAgentIds.size > 0
? this.agentRepository.findSummariesByIds([...maps.allAgentIds])
: [],
]);
const accessibleWfIdSet = new Set(accessibleWfIds);
const accessibleCredIdSet = new Set(accessibleCredIds);
const accessibleDtIdSet = new Set(accessibleDtIds);
const agentNames = new Map<string, { name: string; projectId: string }>();
const wfNames = new Map<string, string>();
const credNames = new Map<string, string>();
const dtNames = new Map<string, { name: string; projectId: string }>();
const existingAgentIds = new Set<string>();
const existingWfIds = new Set<string>();
const existingCredIds = new Set<string>();
const existingDtIds = new Set<string>();
for (const agent of agents) {
existingAgentIds.add(agent.id);
// `null` means global `agent:read`; otherwise access is limited to the listed projects.
if (accessibleAgentProjectIds === null || accessibleAgentProjectIds.has(agent.projectId)) {
agentNames.set(agent.id, {
name: agent.name ?? agent.id,
projectId: agent.projectId,
});
}
}
for (const c of credentials) {
existingCredIds.add(c.id);
if (accessibleCredIdSet.has(c.id)) credNames.set(c.id, c.name ?? c.id);
@ -140,11 +166,12 @@ export class WorkflowDependencyQueryService {
accessibleInputIds,
maps,
{
agentNames,
wfNames,
credNames,
dtNames,
},
{ existingWfIds, existingCredIds, existingDtIds },
{ existingAgentIds, existingWfIds, existingCredIds, existingDtIds },
);
}
@ -156,25 +183,32 @@ export class WorkflowDependencyQueryService {
const accessibleInputIds = await this.filterByAccess(resourceIds, resourceType, user);
if (accessibleInputIds.length === 0) return null;
const rawDeps = await this.dependencyRepository.find({
where: [
{
workflowId: In(accessibleInputIds),
dependencyType: In(['credentialId', 'dataTableId', 'errorWorkflow', 'workflowCall']),
},
{ dependencyKey: In(accessibleInputIds) },
],
select: ['workflowId', 'dependencyType', 'dependencyKey'],
});
const [rawDeps, agentDeps] = await Promise.all([
this.dependencyRepository.find({
where: [
{
workflowId: In(accessibleInputIds),
dependencyType: In(['credentialId', 'dataTableId', 'errorWorkflow', 'workflowCall']),
},
{ dependencyKey: In(accessibleInputIds) },
],
select: ['workflowId', 'dependencyType', 'dependencyKey'],
}),
resourceType === 'credential' && this.moduleRegistry.isActive('agents')
? this.agentDependencyRepository.findByCredentialIds(accessibleInputIds)
: [],
]);
if (rawDeps.length === 0) return null;
if (rawDeps.length === 0 && agentDeps.length === 0) return null;
return { accessibleInputIds, maps: this.buildDepMaps(rawDeps) };
return { accessibleInputIds, maps: this.buildDepMaps(rawDeps, agentDeps) };
}
private buildDepMaps(
rawDeps: Array<{ workflowId: string; dependencyType: string; dependencyKey: string }>,
agentDeps: Array<{ agentId: string; credentialId: string }>,
): RawDepMaps {
const agentUsageMap = new Map<string, Set<string>>();
const credMap = new Map<string, Set<string>>();
const dtMap = new Map<string, Set<string>>();
const subMap = new Map<string, Set<string>>();
@ -182,6 +216,7 @@ export class WorkflowDependencyQueryService {
const errorWfMap = new Map<string, Set<string>>();
const errorWfParentMap = new Map<string, Set<string>>();
const allCredIds = new Set<string>();
const allAgentIds = new Set<string>();
const allWfIds = new Set<string>();
const allDtIds = new Set<string>();
@ -211,7 +246,13 @@ export class WorkflowDependencyQueryService {
}
}
for (const dep of agentDeps) {
addToSet(agentUsageMap, dep.credentialId, dep.agentId);
allAgentIds.add(dep.agentId);
}
return {
agentUsageMap,
credMap,
dtMap,
subMap,
@ -219,6 +260,7 @@ export class WorkflowDependencyQueryService {
errorWfMap,
errorWfParentMap,
allCredIds,
allAgentIds,
allWfIds,
allDtIds,
};
@ -232,11 +274,13 @@ export class WorkflowDependencyQueryService {
resourceIds: string[],
maps: RawDepMaps,
accessMaps: {
agentNames: Map<string, { name: string; projectId: string }>;
wfNames: Map<string, string>;
credNames: Map<string, string>;
dtNames: Map<string, { name: string; projectId: string }>;
},
existing: {
existingAgentIds: Set<string>;
existingWfIds: Set<string>;
existingCredIds: Set<string>;
existingDtIds: Set<string>;
@ -248,6 +292,21 @@ export class WorkflowDependencyQueryService {
const dependencies: ResolvedDependency[] = [];
let inaccessibleCount = 0;
for (const id of maps.agentUsageMap.get(resourceId) ?? []) {
if (!existing.existingAgentIds.has(id)) continue;
const agent = accessMaps.agentNames.get(id);
if (agent) {
dependencies.push({
id,
name: agent.name,
type: 'agentUsage',
projectId: agent.projectId,
});
} else {
inaccessibleCount++;
}
}
const resolve = (
ids: Set<string> | undefined,
nameMap: Map<string, string>,
@ -360,6 +419,15 @@ export class WorkflowDependencyQueryService {
return dataTables.filter((dt) => accessibleProjectIds.has(dt.projectId)).map((dt) => dt.id);
}
private async getAccessibleAgentProjectIds(user: User): Promise<Set<string> | null> {
if (hasGlobalScope(user, 'agent:read')) return null;
const roles = await this.roleService.rolesWithScope('project', ['agent:read']);
return new Set(
await this.projectRelationRepository.getAccessibleProjectsByRoles(user.id, roles),
);
}
}
function addToSet(map: Map<string, Set<string>>, key: string, val: string) {

View File

@ -1,17 +1,37 @@
import { randomUUID } from 'node:crypto';
import {
createWorkflow,
randomCredentialPayload,
shareWorkflowWithUsers,
} from '@n8n/backend-test-utils';
import { WorkflowDependencyRepository } from '@n8n/db';
import { ModuleRegistry } from '@n8n/backend-common';
import { ProjectRepository, WorkflowDependencyRepository, type User } from '@n8n/db';
import { Container } from '@n8n/di';
import { AgentCredentialDependency } from '@/modules/agents/entities/agent-credential-dependency.entity';
import { AgentHistory } from '@/modules/agents/entities/agent-history.entity';
import { Agent } from '@/modules/agents/entities/agent.entity';
import { AgentCredentialDependencyRepository } from '@/modules/agents/repositories/agent-credential-dependency.repository';
import { AgentHistoryRepository } from '@/modules/agents/repositories/agent-history.repository';
import { AgentRepository } from '@/modules/agents/repositories/agent.repository';
import { saveCredential } from '../shared/db/credentials';
import { createMember, createOwner } from '../shared/db/users';
import * as utils from '../shared/utils';
let testServer: ReturnType<typeof utils.setupTestServer>;
let depRepo: WorkflowDependencyRepository;
let agentDepRepo: AgentCredentialDependencyRepository;
let agentHistoryRepo: AgentHistoryRepository;
let agentRepo: AgentRepository;
let projectRepo: ProjectRepository;
beforeAll(() => {
const moduleRegistry = Container.get(ModuleRegistry);
moduleRegistry.entities.push(Agent, AgentHistory, AgentCredentialDependency);
moduleRegistry.getActiveModules().push('agents');
});
testServer = utils.setupTestServer({
endpointGroups: ['workflowDependencies'],
@ -20,6 +40,10 @@ testServer = utils.setupTestServer({
beforeAll(() => {
depRepo = Container.get(WorkflowDependencyRepository);
agentDepRepo = Container.get(AgentCredentialDependencyRepository);
agentHistoryRepo = Container.get(AgentHistoryRepository);
agentRepo = Container.get(AgentRepository);
projectRepo = Container.get(ProjectRepository);
});
/** Seed a workflow_dependency row (draft). */
@ -37,6 +61,46 @@ async function seedDep(workflowId: string, dependencyType: string, dependencyKey
);
}
async function seedAgentCredentialDependencies(
user: User,
credentialId: string,
name = 'Support Agent',
) {
const project = await projectRepo.getPersonalProjectForUserOrFail(user.id);
const agent = agentRepo.create({
name,
projectId: project.id,
schema: {
name,
model: 'openai/gpt-4.1-mini',
instructions: 'Help the user',
credential: credentialId,
tools: [],
skills: [],
},
integrations: [],
tools: {},
skills: {},
versionId: 'draft-version-1',
});
await agentRepo.save(agent);
const publishedVersionId = randomUUID();
await agentHistoryRepo.saveVersion({
versionId: publishedVersionId,
agentId: agent.id,
schema: agent.schema,
tools: agent.tools,
skills: agent.skills,
publishedBy: user,
});
agent.activeVersionId = publishedVersionId;
await agentRepo.save(agent);
await agentDepRepo.refreshForAgent(agent.id);
return agent;
}
describe('POST /workflow-dependencies/counts', () => {
it('should return counts only for workflows the user owns', async () => {
const owner = await createOwner();
@ -97,6 +161,23 @@ describe('POST /workflow-dependencies/counts', () => {
expect(resp.body.data[memberCred.id].workflowParent).toBe(1);
});
it('should count an agent once when draft and published versions use the credential', async () => {
const owner = await createOwner();
const credential = await saveCredential(randomCredentialPayload(), {
user: owner,
role: 'credential:owner',
});
await seedAgentCredentialDependencies(owner, credential.id);
const resp = await testServer
.authAgentFor(owner)
.post('/workflow-dependencies/counts')
.send({ resourceIds: [credential.id], resourceType: 'credential' });
expect(resp.statusCode).toBe(200);
expect(resp.body.data[credential.id].agentUsage).toBe(1);
});
it('should include counts for dependencies the user cannot access', async () => {
const owner = await createOwner();
const member = await createMember();
@ -301,6 +382,54 @@ describe('POST /workflow-dependencies/details', () => {
});
});
it('should resolve an accessible agent using a credential', async () => {
const owner = await createOwner();
const credential = await saveCredential(randomCredentialPayload(), {
user: owner,
role: 'credential:owner',
});
const agent = await seedAgentCredentialDependencies(owner, credential.id, 'Support Agent');
const resp = await testServer
.authAgentFor(owner)
.post('/workflow-dependencies/details')
.send({ resourceIds: [credential.id], resourceType: 'credential' });
expect(resp.statusCode).toBe(200);
expect(resp.body.data[credential.id]).toEqual({
dependencies: [
{
id: agent.id,
name: 'Support Agent',
type: 'agentUsage',
projectId: agent.projectId,
},
],
inaccessibleCount: 0,
});
});
it('should report an inaccessible agent without exposing its details', async () => {
const owner = await createOwner();
const member = await createMember();
const credential = await saveCredential(randomCredentialPayload(), {
user: member,
role: 'credential:owner',
});
await seedAgentCredentialDependencies(owner, credential.id, 'Private Agent');
const resp = await testServer
.authAgentFor(member)
.post('/workflow-dependencies/details')
.send({ resourceIds: [credential.id], resourceType: 'credential' });
expect(resp.statusCode).toBe(200);
expect(resp.body.data[credential.id]).toEqual({
dependencies: [],
inaccessibleCount: 1,
});
});
it('should exclude inaccessible deps and report inaccessibleCount', async () => {
const owner = await createOwner();
const member = await createMember();

View File

@ -4799,6 +4799,7 @@
"workflows.dependencies.type.errorWorkflowParent": "Error handler for",
"workflows.dependencies.type.subWorkflows": "Sub-workflows",
"workflows.dependencies.type.parentWorkflows": "Used by workflows",
"workflows.dependencies.type.agents": "Used by agents",
"workflows.dependencies.search.placeholder": "Search...",
"workflows.dependencies.search.empty": "No matching dependencies",
"workflows.dependencies.hiddenNotice": "+1 not accessible to you | +{count} not accessible to you",

View File

@ -5,6 +5,8 @@ import { useUIStore } from '@/app/stores/ui.store';
import { mockedStore } from '@/__tests__/utils';
import { VIEWS } from '@/app/constants';
import { DATA_TABLE_DETAILS } from '@/features/core/dataTable/constants';
import { AGENT_BUILDER_VIEW } from '@/features/agents/constants';
import { useI18n } from '@n8n/i18n';
import * as vueRouter from 'vue-router';
import type { MockInstance } from 'vitest';
@ -156,6 +158,26 @@ describe('DependencyPill', () => {
expect(items[7].id).toBe('workflowParent:wf-2');
});
it('should group agent usages under the agents i18n label', () => {
const baseTextSpy = vi.spyOn(useI18n(), 'baseText');
mockDepsResult = {
dependencies: [
{ type: 'agentUsage', id: 'agent-1', name: 'Support Agent', projectId: 'proj-1' },
],
inaccessibleCount: 0,
};
renderComponent({ props: defaultProps });
const items = capturedItems as Array<{ id: string; label: string; disabled?: boolean }>;
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ id: 'header-agentUsage', disabled: true });
expect(baseTextSpy).toHaveBeenCalledWith('workflows.dependencies.type.agents');
expect(items[1].id).toBe('agentUsage:agent-1');
baseTextSpy.mockRestore();
});
it('should open credential on select', () => {
mockDepsResult = {
dependencies: [{ type: 'credentialId', id: 'cred-1', name: 'My Key' }],
@ -229,6 +251,24 @@ describe('DependencyPill', () => {
expect(windowOpenSpy).not.toHaveBeenCalled();
});
it('should open an agent using the credential in a new tab on select', () => {
mockDepsResult = {
dependencies: [
{ type: 'agentUsage', id: 'agent-1', name: 'Support Agent', projectId: 'proj-1' },
],
inaccessibleCount: 0,
};
renderComponent({ props: defaultProps });
capturedSelectHandler?.('agentUsage:agent-1');
expect(router.resolve).toHaveBeenCalledWith({
name: AGENT_BUILDER_VIEW,
params: { projectId: 'proj-1', agentId: 'agent-1' },
});
expect(windowOpenSpy).toHaveBeenCalledWith('/mock-href', '_blank');
});
it('should ignore select with invalid value', () => {
mockDepsResult = createDepsResult();
renderComponent({ props: defaultProps });

View File

@ -11,6 +11,7 @@ import { useUIStore } from '@/app/stores/ui.store';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import type { DependencyType, ResolvedDependency } from '@n8n/api-types';
import { useDependencies } from '@/app/composables/useDependencies';
import { AGENT_BUILDER_VIEW } from '@/features/agents/constants';
import { DATA_TABLE_DETAILS } from '@/features/core/dataTable/constants';
const MIN_ITEMS_FOR_SEARCH = 6;
@ -63,6 +64,10 @@ const typeConfig: Record<DependencyType, { icon: IconName; labelKey: BaseTextKey
icon: 'table',
labelKey: 'workflows.dependencies.type.dataTables' as BaseTextKey,
},
agentUsage: {
icon: 'bot',
labelKey: 'workflows.dependencies.type.agents' as BaseTextKey,
},
errorWorkflow: {
icon: 'bug',
labelKey: 'workflows.dependencies.type.errorWorkflow' as BaseTextKey,
@ -86,6 +91,7 @@ const displayOrder: DependencyType[] = [
'dataTableId',
'workflowCall',
'workflowParent',
'agentUsage',
'errorWorkflow',
'errorWorkflowParent',
];
@ -100,6 +106,7 @@ const menuItems = computed(() => {
const groups: Record<DependencyType, ResolvedDependency[]> = {
credentialId: [],
dataTableId: [],
agentUsage: [],
errorWorkflow: [],
errorWorkflowParent: [],
workflowCall: [],
@ -171,6 +178,15 @@ function onSelect(value: string) {
window.open(href, '_blank');
}
break;
case 'agentUsage':
if (dep.projectId) {
const href = router.resolve({
name: AGENT_BUILDER_VIEW,
params: { projectId: dep.projectId, agentId: dep.id },
}).href;
window.open(href, '_blank');
}
break;
}
}