diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index 13aa56c7d7c..63e6a6a6e82 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -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 diff --git a/docs/generated/postgres-schema/public.agent_credential_dependency.md b/docs/generated/postgres-schema/public.agent_credential_dependency.md new file mode 100644 index 00000000000..35482087357 --- /dev/null +++ b/docs/generated/postgres-schema/public.agent_credential_dependency.md @@ -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) diff --git a/docs/generated/postgres-schema/public.agents.md b/docs/generated/postgres-schema/public.agents.md index fa4ed6f9570..1e7807e49a7 100644 --- a/docs/generated/postgres-schema/public.agents.md +++ b/docs/generated/postgres-schema/public.agents.md @@ -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 diff --git a/docs/generated/postgres-schema/public.credentials_entity.md b/docs/generated/postgres-schema/public.credentials_entity.md index 2f86bb6fb02..b7f144f087c 100644 --- a/docs/generated/postgres-schema/public.credentials_entity.md +++ b/docs/generated/postgres-schema/public.credentials_entity.md @@ -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 diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 7182604a07c..dad2bace939 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -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 diff --git a/docs/generated/sqlite-schema/agent_credential_dependency.md b/docs/generated/sqlite-schema/agent_credential_dependency.md new file mode 100644 index 00000000000..8c5026566cb --- /dev/null +++ b/docs/generated/sqlite-schema/agent_credential_dependency.md @@ -0,0 +1,85 @@ +# agent_credential_dependency + +## Description + +
+Table Definition + +```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")) +``` + +
+ +## 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) diff --git a/docs/generated/sqlite-schema/agents.md b/docs/generated/sqlite-schema/agents.md index 3934c965d7d..9fe1d9018a5 100644 --- a/docs/generated/sqlite-schema/agents.md +++ b/docs/generated/sqlite-schema/agents.md @@ -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 diff --git a/docs/generated/sqlite-schema/credentials_entity.md b/docs/generated/sqlite-schema/credentials_entity.md index 51be82bd4d3..5fa537d3546 100644 --- a/docs/generated/sqlite-schema/credentials_entity.md +++ b/docs/generated/sqlite-schema/credentials_entity.md @@ -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 diff --git a/packages/@n8n/api-types/src/schemas/dependency-counts.schema.ts b/packages/@n8n/api-types/src/schemas/dependency-counts.schema.ts index a8320f8f0b3..2e0337c945d 100644 --- a/packages/@n8n/api-types/src/schemas/dependency-counts.schema.ts +++ b/packages/@n8n/api-types/src/schemas/dependency-counts.schema.ts @@ -1,4 +1,5 @@ export interface DependencyTypeCounts { + agentUsage: number; credentialId: number; dataTableId: number; errorWorkflow: number; diff --git a/packages/@n8n/api-types/src/schemas/dependency.schema.ts b/packages/@n8n/api-types/src/schemas/dependency.schema.ts index 20d0c9ef1a7..2715f80d0fd 100644 --- a/packages/@n8n/api-types/src/schemas/dependency.schema.ts +++ b/packages/@n8n/api-types/src/schemas/dependency.schema.ts @@ -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; } diff --git a/packages/@n8n/db/src/migrations/common/1785828155092-CreateAgentCredentialDependencyTable.ts b/packages/@n8n/db/src/migrations/common/1785828155092-CreateAgentCredentialDependencyTable.ts new file mode 100644 index 00000000000..d77c705f283 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1785828155092-CreateAgentCredentialDependencyTable.ts @@ -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'); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 9fcafac14c1..fc763e69d84 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -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, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index e8fffaa8759..221ba629c25 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -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 }; diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index a1df0e05569..7220680e64a 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -1057,6 +1057,10 @@ export type RelayEventMap = { // #endregion // region Agents + 'agent-saved': { + agentId: string; + }; + 'agent-deleted': { agentId: string; projectId: string; diff --git a/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts index c716d06a2dc..91b08404221 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts @@ -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(); const credentialsService = mock(); const workflowRepository = mock(); + const eventService = mock(); const agentValidationService = mock(); const telemetry = mock(); @@ -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 () => { diff --git a/packages/cli/src/modules/agents/__tests__/agent-credential-dependency.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent-credential-dependency.repository.test.ts new file mode 100644 index 00000000000..3f302c856ad --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-credential-dependency.repository.test.ts @@ -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; + let credentialQuery: MockProxy>; + + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(entityManager.connection, { options: { type: 'sqlite' } }); + txRunner = mock(); + txRunner.run.mockImplementation( + async (ctx: OperationContext, fn: (ctx: OperationContext) => Promise) => await fn(ctx), + ); + credentialQuery = mock>(); + 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({ id: 'credential-draft' }), + mock({ id: 'credential-shared' }), + mock({ id: 'credential-integration' }), + mock({ 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({ 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({ 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], + ); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-credential-index.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-credential-index.service.test.ts new file mode 100644 index 00000000000..63487b2e13d --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-credential-index.service.test.ts @@ -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(); + const agentRepository = mock(); + const workflowsConfig = mock({ indexingBatchSize: batchSize }); + const logger = mock(); + 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), + }); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts index 1ccbd4dd6a7..058dc107fdb 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts @@ -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(); const runtimeCacheService = mock(); const chatIntegrationRegistry = mock(); + const eventService = mock(); const telemetry = mock(); const credentialsService = mock(); const setupCompletionService = mock(); @@ -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' }, diff --git a/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts index 57b8ba2ebff..04089aa8b03 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts @@ -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(); const credentialsService = mock(); const telemetry = mock(); + const eventService = mock(); 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, diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts index fc96e09793f..54ed75a2737 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts @@ -254,6 +254,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => { runtimeCacheService, credentialsService, mock(), + mock(), mock(), modificationTelemetry, ); @@ -278,6 +279,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => { chatIntegrationService, runtimeCacheService, chatIntegrationRegistry, + mock(), modificationTelemetry, credentialsService, mock(), @@ -303,6 +305,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => { agentValidationService, credentialsService, telemetry, + mock(), mock(), mock(), ); diff --git a/packages/cli/src/modules/agents/agent-config.service.ts b/packages/cli/src/modules/agents/agent-config.service.ts index c3a198264ac..06d104b4302 100644 --- a/packages/cli/src/modules/agents/agent-config.service.ts +++ b/packages/cli/src/modules/agents/agent-config.service.ts @@ -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({ diff --git a/packages/cli/src/modules/agents/agent-credential-index.listener.ts b/packages/cli/src/modules/agents/agent-credential-index.listener.ts new file mode 100644 index 00000000000..71c2ff31809 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-credential-index.listener.ts @@ -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): Promise { + try { + await operation(); + } catch (error) { + this.logger.error(`Failed to ${action}`, { error }); + } + } +} diff --git a/packages/cli/src/modules/agents/agent-credential-index.service.ts b/packages/cli/src/modules/agents/agent-credential-index.service.ts new file mode 100644 index 00000000000..4b88ca8b5ac --- /dev/null +++ b/packages/cli/src/modules/agents/agent-credential-index.service.ts @@ -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 { + await this.dependencyRepository.removeForAgent(agentId); + } + + async refresh(agentId: string): Promise { + await this.dependencyRepository.refreshForAgent(agentId); + } + + async buildIndex(): Promise { + 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; + } + } +} diff --git a/packages/cli/src/modules/agents/agent-integration-persistence.service.ts b/packages/cli/src/modules/agents/agent-integration-persistence.service.ts index 8d065d17f84..f4abeda3fb6 100644 --- a/packages/cli/src/modules/agents/agent-integration-persistence.service.ts +++ b/packages/cli/src/modules/agents/agent-integration-persistence.service.ts @@ -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, diff --git a/packages/cli/src/modules/agents/agent-publish.service.ts b/packages/cli/src/modules/agents/agent-publish.service.ts index e93ef05d87f..eb3a9730124 100644 --- a/packages/cli/src/modules/agents/agent-publish.service.ts +++ b/packages/cli/src/modules/agents/agent-publish.service.ts @@ -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, { diff --git a/packages/cli/src/modules/agents/agents.module.ts b/packages/cli/src/modules/agents/agents.module.ts index 409cf74c6ea..34caf87f065 100644 --- a/packages/cli/src/modules/agents/agents.module.ts +++ b/packages/cli/src/modules/agents/agents.module.ts @@ -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, diff --git a/packages/cli/src/modules/agents/entities/agent-credential-dependency.entity.ts b/packages/cli/src/modules/agents/entities/agent-credential-dependency.entity.ts new file mode 100644 index 00000000000..28da9964a4d --- /dev/null +++ b/packages/cli/src/modules/agents/entities/agent-credential-dependency.entity.ts @@ -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; + + @Index() + @PrimaryColumn({ type: 'varchar', length: 36 }) + credentialId: string; + + @ManyToOne('CredentialsEntity', { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'credentialId' }) + credential: Relation; +} diff --git a/packages/cli/src/modules/agents/repositories/agent-credential-dependency.repository.ts b/packages/cli/src/modules/agents/repositories/agent-credential-dependency.repository.ts new file mode 100644 index 00000000000..8bac460a333 --- /dev/null +++ b/packages/cli/src/modules/agents/repositories/agent-credential-dependency.repository.ts @@ -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 { + constructor( + dataSource: DataSource, + private readonly txRunner: TransactionRunner, + ) { + super(AgentCredentialDependency, dataSource.manager); + } + + async refreshForAgent(agentId: string): Promise { + 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 { + await this.managerFor({}).delete(AgentCredentialDependency, { agentId }); + } + + async findByCredentialIds( + credentialIds: string[], + ): Promise { + if (credentialIds.length === 0) return []; + + return await this.find({ + select: ['agentId', 'credentialId'], + where: { credentialId: In(credentialIds) }, + }); + } + + private async findExistingCredentialIds( + manager: EntityManager, + credentialIds: ReadonlySet, + lockForUpdate: boolean, + ): Promise> { + 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)); + } +} diff --git a/packages/cli/src/modules/agents/repositories/agent.repository.ts b/packages/cli/src/modules/agents/repositories/agent.repository.ts index 8c82723ba31..883904706d9 100644 --- a/packages/cli/src/modules/agents/repositories/agent.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent.repository.ts @@ -159,6 +159,33 @@ export class AgentRepository extends Repository { }); } + async findCredentialIndexAgentIdsBatch( + afterId: string | null, + batchSize: number, + ): Promise>> { + 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>> { + if (ids.length === 0) return []; + + return await this.find({ + select: ['id', 'name', 'projectId'], + where: { id: In(ids) }, + }); + } + async findByIdInProjects(id: string, projectIds: string[]): Promise { if (projectIds.length === 0) return null; return await this.findOne({ diff --git a/packages/cli/src/modules/agents/utils/__tests__/extract-agent-credential-ids.test.ts b/packages/cli/src/modules/agents/utils/__tests__/extract-agent-credential-ids.test.ts new file mode 100644 index 00000000000..532f77abb6e --- /dev/null +++ b/packages/cli/src/modules/agents/utils/__tests__/extract-agent-credential-ids.test.ts @@ -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']), + ); + }); +}); diff --git a/packages/cli/src/modules/agents/utils/extract-agent-credential-ids.ts b/packages/cli/src/modules/agents/utils/extract-agent-credential-ids.ts new file mode 100644 index 00000000000..160d6f5cafe --- /dev/null +++ b/packages/cli/src/modules/agents/utils/extract-agent-credential-ids.ts @@ -0,0 +1,42 @@ +import { AI_GATEWAY_MANAGED_TAG, MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types'; + +const MANAGED_CREDENTIAL_IDS = new Set([MANAGED_CREDENTIAL_TOKEN, AI_GATEWAY_MANAGED_TAG]); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function addCredentialId(value: unknown, credentialIds: Set): void { + if (typeof value === 'string' && value !== '' && !MANAGED_CREDENTIAL_IDS.has(value)) { + credentialIds.add(value); + } +} + +function collectCredentialIds(value: unknown, credentialIds: Set): 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 { + const credentialIds = new Set(); + collectCredentialIds(value, credentialIds); + return credentialIds; +} diff --git a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts index 650f5d60b9c..01b7df913f6 100644 --- a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts @@ -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(), mock(), modificationTelemetry, ); diff --git a/packages/cli/src/modules/workflow-index/workflow-dependency-query.service.ts b/packages/cli/src/modules/workflow-index/workflow-dependency-query.service.ts index cef7ff23b3a..6662c4eec84 100644 --- a/packages/cli/src/modules/workflow-index/workflow-dependency-query.service.ts +++ b/packages/cli/src/modules/workflow-index/workflow-dependency-query.service.ts @@ -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>; credMap: Map>; dtMap: Map>; subMap: Map>; @@ -28,6 +32,7 @@ interface RawDepMaps { errorWfMap: Map>; errorWfParentMap: Map>; allCredIds: Set; + allAgentIds: Set; allWfIds: Set; allDtIds: Set; } @@ -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(), + ]); // 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(); const wfNames = new Map(); const credNames = new Map(); const dtNames = new Map(); + const existingAgentIds = new Set(); const existingWfIds = new Set(); const existingCredIds = new Set(); const existingDtIds = new Set(); + 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>(); const credMap = new Map>(); const dtMap = new Map>(); const subMap = new Map>(); @@ -182,6 +216,7 @@ export class WorkflowDependencyQueryService { const errorWfMap = new Map>(); const errorWfParentMap = new Map>(); const allCredIds = new Set(); + const allAgentIds = new Set(); const allWfIds = new Set(); const allDtIds = new Set(); @@ -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; wfNames: Map; credNames: Map; dtNames: Map; }, existing: { + existingAgentIds: Set; existingWfIds: Set; existingCredIds: Set; existingDtIds: Set; @@ -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 | undefined, nameMap: Map, @@ -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 | 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>, key: string, val: string) { diff --git a/packages/cli/test/integration/workflows/workflow-dependency.controller.test.ts b/packages/cli/test/integration/workflows/workflow-dependency.controller.test.ts index 6d8ffadd4cd..8eae6f6bb16 100644 --- a/packages/cli/test/integration/workflows/workflow-dependency.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflow-dependency.controller.test.ts @@ -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; 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(); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index c032281d6bc..64b859aaca1 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/app/components/DependencyPill.test.ts b/packages/frontend/editor-ui/src/app/components/DependencyPill.test.ts index 59fe1b71a7f..eb4402a9df5 100644 --- a/packages/frontend/editor-ui/src/app/components/DependencyPill.test.ts +++ b/packages/frontend/editor-ui/src/app/components/DependencyPill.test.ts @@ -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 }); diff --git a/packages/frontend/editor-ui/src/app/components/DependencyPill.vue b/packages/frontend/editor-ui/src/app/components/DependencyPill.vue index 1067a4b29e7..de3fefb2330 100644 --- a/packages/frontend/editor-ui/src/app/components/DependencyPill.vue +++ b/packages/frontend/editor-ui/src/app/components/DependencyPill.vue @@ -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 { const groups: Record = { 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; } }