mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(core): Show Agent references in credential usage (#35409)
This commit is contained in:
parent
4103f78638
commit
a74c6fd11f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
85
docs/generated/sqlite-schema/agent_credential_dependency.md
Normal file
85
docs/generated/sqlite-schema/agent_credential_dependency.md
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export interface DependencyTypeCounts {
|
||||
agentUsage: number;
|
||||
credentialId: number;
|
||||
dataTableId: number;
|
||||
errorWorkflow: number;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -1057,6 +1057,10 @@ export type RelayEventMap = {
|
|||
// #endregion
|
||||
|
||||
// region Agents
|
||||
'agent-saved': {
|
||||
agentId: string;
|
||||
};
|
||||
|
||||
'agent-deleted': {
|
||||
agentId: string;
|
||||
projectId: string;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user