feat(core): Index agent workflow tool dependencies and invalidate dependent runtimes (#37857)

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Robin Braumann
2026-09-07 17:14:03 +00:00
committed by GitHub
co-authored by Cursor
parent 93945cce5d
commit 19eba4305b
37 changed files with 1208 additions and 180 deletions
+8
View File
@@ -26,6 +26,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.agent_task_definition](public.agent_task_definition.md) | 8 | | BASE TABLE |
| [public.agent_task_run_lock](public.agent_task_run_lock.md) | 6 | | BASE TABLE |
| [public.agent_task_snapshot](public.agent_task_snapshot.md) | 9 | | BASE TABLE |
| [public.agent_workflow_dependency](public.agent_workflow_dependency.md) | 3 | | BASE TABLE |
| [public.agents](public.agents.md) | 14 | | BASE TABLE |
| [public.agents_memory_entries](public.agents_memory_entries.md) | 13 | | BASE TABLE |
| [public.agents_memory_entry_cursors](public.agents_memory_entry_cursors.md) | 6 | | BASE TABLE |
@@ -194,6 +195,8 @@ erDiagram
"public.agent_task_definition" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_task_run_lock" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_task_snapshot" }o--|| "public.agent_history" : "FOREIGN KEY (#quot;versionId#quot;) REFERENCES agent_history(#quot;versionId#quot;) ON DELETE CASCADE"
"public.agent_workflow_dependency" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.agent_workflow_dependency" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agents" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.agents" }o--o| "public.agent_history" : "FOREIGN KEY (#quot;activeVersionId#quot;) REFERENCES agent_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.agents_memory_entries" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
@@ -588,6 +591,11 @@ erDiagram
timestamp_3__with_time_zone updatedAt
varchar_36_ versionId FK
}
"public.agent_workflow_dependency" {
varchar_36_ agentId FK
timestamp_3__with_time_zone createdAt
varchar_36_ workflowId FK
}
"public.agents" {
varchar_36_ activeVersionId FK
boolean availableInMCP
@@ -0,0 +1,84 @@
# public.agent_workflow_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 | | | |
| workflowId | varchar(36) | | false | | [public.workflow_entity](public.workflow_entity.md) | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| FK_a2694df196a66064edbdbe38b94 | FOREIGN KEY | FOREIGN KEY ("workflowId") REFERENCES workflow_entity(id) ON DELETE CASCADE |
| FK_e5511a5e9eab71b7e516bdc04e9 | FOREIGN KEY | FOREIGN KEY ("agentId") REFERENCES agents(id) ON DELETE CASCADE |
| PK_4b9cb5e8db0dd16bb843b76465a | PRIMARY KEY | PRIMARY KEY ("agentId", "workflowId") |
| agent_workflow_dependency_agentId_not_null | n | NOT NULL "agentId" |
| agent_workflow_dependency_createdAt_not_null | n | NOT NULL "createdAt" |
| agent_workflow_dependency_workflowId_not_null | n | NOT NULL "workflowId" |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_a2694df196a66064edbdbe38b9 | CREATE INDEX "IDX_a2694df196a66064edbdbe38b9" ON public.agent_workflow_dependency USING btree ("workflowId") |
| PK_4b9cb5e8db0dd16bb843b76465a | CREATE UNIQUE INDEX "PK_4b9cb5e8db0dd16bb843b76465a" ON public.agent_workflow_dependency USING btree ("agentId", "workflowId") |
## Relations
```mermaid
erDiagram
"public.agent_workflow_dependency" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_workflow_dependency" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.agent_workflow_dependency" {
varchar_36_ agentId FK
timestamp_3__with_time_zone createdAt
varchar_36_ workflowId 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
integer revision
json schema
timestamp_3__with_time_zone setupCompletedAt
json skills
json tools
timestamp_3__with_time_zone updatedAt
varchar_36_ versionId
}
"public.workflow_entity" {
boolean active
varchar_36_ activeVersionId FK
json connections
timestamp_3__with_time_zone createdAt
text description
varchar_36_ id
boolean isArchived
json meta
varchar_128_ name
json nodeGroups
json nodes
varchar_36_ parentFolderId FK
json pinData
json settings
varchar sourceWorkflowId
json staticData
integer triggerCount
timestamp_3__with_time_zone updatedAt
integer versionCounter
character_36_ versionId
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)
+7 -1
View File
@@ -7,7 +7,7 @@
| activeVersionId | varchar(36) | | true | | [public.agent_history](public.agent_history.md) | |
| availableInMCP | boolean | false | false | | | Whether MCP clients granted agent scopes may operate on this agent |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| id | varchar(36) | | false | [public.agent_background_job](public.agent_background_job.md) [public.agent_channel_status](public.agent_channel_status.md) [public.agent_chat_attachments](public.agent_chat_attachments.md) [public.agent_chat_subscriptions](public.agent_chat_subscriptions.md) [public.agent_checkpoints](public.agent_checkpoints.md) [public.agent_credential_dependency](public.agent_credential_dependency.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_files](public.agent_files.md) [public.agent_history](public.agent_history.md) [public.agent_task_definition](public.agent_task_definition.md) [public.agent_task_run_lock](public.agent_task_run_lock.md) [public.agents_memory_entries](public.agents_memory_entries.md) [public.agents_memory_entry_cursors](public.agents_memory_entry_cursors.md) [public.agents_memory_entry_locks](public.agents_memory_entry_locks.md) [public.agents_memory_entry_sources](public.agents_memory_entry_sources.md) [public.agents_observation_cursors](public.agents_observation_cursors.md) [public.agents_observation_locks](public.agents_observation_locks.md) [public.agents_observations](public.agents_observations.md) | | |
| id | varchar(36) | | false | [public.agent_background_job](public.agent_background_job.md) [public.agent_channel_status](public.agent_channel_status.md) [public.agent_chat_attachments](public.agent_chat_attachments.md) [public.agent_chat_subscriptions](public.agent_chat_subscriptions.md) [public.agent_checkpoints](public.agent_checkpoints.md) [public.agent_credential_dependency](public.agent_credential_dependency.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_files](public.agent_files.md) [public.agent_history](public.agent_history.md) [public.agent_task_definition](public.agent_task_definition.md) [public.agent_task_run_lock](public.agent_task_run_lock.md) [public.agent_workflow_dependency](public.agent_workflow_dependency.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) | |
@@ -63,6 +63,7 @@ erDiagram
"public.agent_history" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_task_definition" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_task_run_lock" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_workflow_dependency" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agents_memory_entries" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agents_memory_entry_cursors" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agents_memory_entry_locks" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
@@ -226,6 +227,11 @@ erDiagram
varchar_32_ taskId
timestamp_3__with_time_zone updatedAt
}
"public.agent_workflow_dependency" {
varchar_36_ agentId FK
timestamp_3__with_time_zone createdAt
varchar_36_ workflowId FK
}
"public.agents_memory_entries" {
varchar_36_ agentId FK
text content
+7 -1
View File
@@ -9,7 +9,7 @@
| connections | json | | false | | | |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| description | text | | true | | | |
| id | varchar(36) | | false | [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.chat_hub_messages](public.chat_hub_messages.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.evaluation_collection](public.evaluation_collection.md) [public.evaluation_config](public.evaluation_config.md) [public.execution_entity](public.execution_entity.md) [public.insights_metadata](public.insights_metadata.md) [public.poller_state](public.poller_state.md) [public.processed_data](public.processed_data.md) [public.shared_workflow](public.shared_workflow.md) [public.test_run](public.test_run.md) [public.webhook_entity](public.webhook_entity.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_dependency](public.workflow_dependency.md) [public.workflow_history](public.workflow_history.md) [public.workflow_publication_trigger_status](public.workflow_publication_trigger_status.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_published_version](public.workflow_published_version.md) [public.workflow_review_request_workflow](public.workflow_review_request_workflow.md) [public.workflows_tags](public.workflows_tags.md) | | |
| id | varchar(36) | | false | [public.agent_workflow_dependency](public.agent_workflow_dependency.md) [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.chat_hub_messages](public.chat_hub_messages.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.evaluation_collection](public.evaluation_collection.md) [public.evaluation_config](public.evaluation_config.md) [public.execution_entity](public.execution_entity.md) [public.insights_metadata](public.insights_metadata.md) [public.poller_state](public.poller_state.md) [public.processed_data](public.processed_data.md) [public.shared_workflow](public.shared_workflow.md) [public.test_run](public.test_run.md) [public.webhook_entity](public.webhook_entity.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_dependency](public.workflow_dependency.md) [public.workflow_history](public.workflow_history.md) [public.workflow_publication_trigger_status](public.workflow_publication_trigger_status.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_published_version](public.workflow_published_version.md) [public.workflow_review_request_workflow](public.workflow_review_request_workflow.md) [public.workflows_tags](public.workflows_tags.md) | | |
| isArchived | boolean | false | false | | | |
| meta | json | | true | | | |
| name | varchar(128) | | false | | | |
@@ -66,6 +66,7 @@
erDiagram
"public.workflow_entity" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;activeVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE RESTRICT"
"public.agent_workflow_dependency" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.ai_builder_temporary_workflow" |o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.chat_hub_messages" }o--o| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE SET NULL"
"public.chat_hub_sessions" }o--o| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE SET NULL"
@@ -123,6 +124,11 @@ erDiagram
varchar_36_ versionId
varchar_36_ workflowId FK
}
"public.agent_workflow_dependency" {
varchar_36_ agentId FK
timestamp_3__with_time_zone createdAt
varchar_36_ workflowId FK
}
"public.ai_builder_temporary_workflow" {
timestamp_3__with_time_zone createdAt
uuid threadId FK
+8
View File
@@ -26,6 +26,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [agent_task_definition](agent_task_definition.md) | 8 | | table |
| [agent_task_run_lock](agent_task_run_lock.md) | 6 | | table |
| [agent_task_snapshot](agent_task_snapshot.md) | 9 | | table |
| [agent_workflow_dependency](agent_workflow_dependency.md) | 3 | | table |
| [agents](agents.md) | 14 | | table |
| [agents_memory_entries](agents_memory_entries.md) | 13 | | table |
| [agents_memory_entry_cursors](agents_memory_entry_cursors.md) | 6 | | table |
@@ -177,6 +178,8 @@ erDiagram
"agent_task_definition" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_task_run_lock" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_task_snapshot" |o--|| "agent_history" : "FOREIGN KEY (versionId) REFERENCES agent_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_workflow_dependency" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_workflow_dependency" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agents" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agents" }o--o| "agent_history" : "FOREIGN KEY (activeVersionId) REFERENCES agent_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agents_memory_entries" }o--o| "agents_memory_entries" : "FOREIGN KEY (supersededBy) REFERENCES agents_memory_entries (id) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE"
@@ -575,6 +578,11 @@ erDiagram
datetime_3_ updatedAt
varchar_36_ versionId PK
}
"agent_workflow_dependency" {
varchar_36_ agentId PK
datetime_3_ createdAt
varchar_36_ workflowId PK
}
"agents" {
varchar_36_ activeVersionId FK
boolean availableInMCP
@@ -0,0 +1,94 @@
# agent_workflow_dependency
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "agent_workflow_dependency" ("agentId" varchar(36) NOT NULL, "workflowId" varchar(36) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_e5511a5e9eab71b7e516bdc04e9" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE, CONSTRAINT "FK_a2694df196a66064edbdbe38b94" FOREIGN KEY ("workflowId") REFERENCES "workflow_entity" ("id") ON DELETE CASCADE, PRIMARY KEY ("agentId", "workflowId"))
```
</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 | | | |
| workflowId | varchar(36) | | false | | [workflow_entity](workflow_entity.md) | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (workflowId) REFERENCES workflow_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) |
| sqlite_autoindex_agent_workflow_dependency_1 | PRIMARY KEY | PRIMARY KEY (agentId, workflowId) |
| workflowId | PRIMARY KEY | PRIMARY KEY (workflowId) |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_a2694df196a66064edbdbe38b9 | CREATE INDEX "IDX_a2694df196a66064edbdbe38b9" ON "agent_workflow_dependency" ("workflowId") |
| sqlite_autoindex_agent_workflow_dependency_1 | PRIMARY KEY (agentId, workflowId) |
## Relations
```mermaid
erDiagram
"agent_workflow_dependency" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_workflow_dependency" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_workflow_dependency" {
varchar_36_ agentId PK
datetime_3_ createdAt
varchar_36_ workflowId PK
}
"agents" {
varchar_36_ activeVersionId FK
boolean availableInMCP
datetime_3_ createdAt
varchar_36_ id PK
TEXT integrations
varchar_128_ name
varchar_255_ projectId FK
INTEGER revision
TEXT schema
datetime_3_ setupCompletedAt
TEXT skills
TEXT tools
datetime_3_ updatedAt
varchar_36_ versionId
}
"workflow_entity" {
boolean active
varchar_36_ activeVersionId FK
TEXT connections
datetime_3_ createdAt
TEXT description
varchar_36_ id PK
boolean isArchived
TEXT meta
varchar_128_ name
TEXT nodeGroups
TEXT nodes
varchar_36_ parentFolderId FK
TEXT pinData
TEXT settings
varchar sourceWorkflowId
TEXT staticData
INTEGER triggerCount
datetime_3_ updatedAt
INTEGER versionCounter
varchar_36_ versionId
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)
+7 -1
View File
@@ -18,7 +18,7 @@ CREATE TABLE "agents" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128
| activeVersionId | varchar(36) | | true | | [agent_history](agent_history.md) | |
| availableInMCP | boolean | false | false | | | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| id | varchar(36) | | false | [agent_background_job](agent_background_job.md) [agent_channel_status](agent_channel_status.md) [agent_chat_attachments](agent_chat_attachments.md) [agent_chat_subscriptions](agent_chat_subscriptions.md) [agent_checkpoints](agent_checkpoints.md) [agent_credential_dependency](agent_credential_dependency.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_execution_threads](agent_execution_threads.md) [agent_files](agent_files.md) [agent_history](agent_history.md) [agent_task_definition](agent_task_definition.md) [agent_task_run_lock](agent_task_run_lock.md) [agents_memory_entries](agents_memory_entries.md) [agents_memory_entry_cursors](agents_memory_entry_cursors.md) [agents_memory_entry_locks](agents_memory_entry_locks.md) [agents_memory_entry_sources](agents_memory_entry_sources.md) [agents_observation_cursors](agents_observation_cursors.md) [agents_observation_locks](agents_observation_locks.md) [agents_observations](agents_observations.md) | | |
| id | varchar(36) | | false | [agent_background_job](agent_background_job.md) [agent_channel_status](agent_channel_status.md) [agent_chat_attachments](agent_chat_attachments.md) [agent_chat_subscriptions](agent_chat_subscriptions.md) [agent_checkpoints](agent_checkpoints.md) [agent_credential_dependency](agent_credential_dependency.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_execution_threads](agent_execution_threads.md) [agent_files](agent_files.md) [agent_history](agent_history.md) [agent_task_definition](agent_task_definition.md) [agent_task_run_lock](agent_task_run_lock.md) [agent_workflow_dependency](agent_workflow_dependency.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) | |
@@ -65,6 +65,7 @@ erDiagram
"agent_history" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_task_definition" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_task_run_lock" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_workflow_dependency" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agents_memory_entries" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agents_memory_entry_cursors" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agents_memory_entry_locks" |o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -228,6 +229,11 @@ erDiagram
varchar_32_ taskId PK
datetime_3_ updatedAt
}
"agent_workflow_dependency" {
varchar_36_ agentId PK
datetime_3_ createdAt
varchar_36_ workflowId PK
}
"agents_memory_entries" {
varchar_36_ agentId FK
TEXT content
+7 -1
View File
@@ -20,7 +20,7 @@ CREATE TABLE "workflow_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" va
| connections | TEXT | | true | | | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| description | TEXT | | true | | | |
| id | varchar(36) | | false | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [chat_hub_messages](chat_hub_messages.md) [chat_hub_sessions](chat_hub_sessions.md) [evaluation_collection](evaluation_collection.md) [evaluation_config](evaluation_config.md) [execution_entity](execution_entity.md) [insights_metadata](insights_metadata.md) [poller_state](poller_state.md) [processed_data](processed_data.md) [shared_workflow](shared_workflow.md) [test_run](test_run.md) [workflow_builder_session](workflow_builder_session.md) [workflow_dependency](workflow_dependency.md) [workflow_history](workflow_history.md) [workflow_publication_trigger_status](workflow_publication_trigger_status.md) [workflow_publish_history](workflow_publish_history.md) [workflow_published_version](workflow_published_version.md) [workflow_review_request_workflow](workflow_review_request_workflow.md) [workflows_tags](workflows_tags.md) | | |
| id | varchar(36) | | false | [agent_workflow_dependency](agent_workflow_dependency.md) [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [chat_hub_messages](chat_hub_messages.md) [chat_hub_sessions](chat_hub_sessions.md) [evaluation_collection](evaluation_collection.md) [evaluation_config](evaluation_config.md) [execution_entity](execution_entity.md) [insights_metadata](insights_metadata.md) [poller_state](poller_state.md) [processed_data](processed_data.md) [shared_workflow](shared_workflow.md) [test_run](test_run.md) [workflow_builder_session](workflow_builder_session.md) [workflow_dependency](workflow_dependency.md) [workflow_history](workflow_history.md) [workflow_publication_trigger_status](workflow_publication_trigger_status.md) [workflow_publish_history](workflow_publish_history.md) [workflow_published_version](workflow_published_version.md) [workflow_review_request_workflow](workflow_review_request_workflow.md) [workflows_tags](workflows_tags.md) | | |
| isArchived | boolean | FALSE | false | | | |
| meta | TEXT | | true | | | |
| name | varchar(128) | | false | | | |
@@ -65,6 +65,7 @@ CREATE TABLE "workflow_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" va
erDiagram
"workflow_entity" }o--o| "workflow_history" : "FOREIGN KEY (activeVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE"
"agent_workflow_dependency" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"ai_builder_temporary_workflow" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"chat_hub_messages" }o--o| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"chat_hub_sessions" }o--o| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
@@ -122,6 +123,11 @@ erDiagram
varchar_36_ versionId PK
varchar_36_ workflowId FK
}
"agent_workflow_dependency" {
varchar_36_ agentId PK
datetime_3_ createdAt
varchar_36_ workflowId PK
}
"ai_builder_temporary_workflow" {
datetime_3_ createdAt
varchar threadId FK
@@ -0,0 +1,23 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class CreateAgentWorkflowDependencyTable1788522448804 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable('agent_workflow_dependency')
.withColumns(column('agentId').varchar(36).primary, column('workflowId').varchar(36).primary)
.withForeignKey('agentId', {
tableName: 'agents',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withIndexOn(['workflowId']).withCreatedAt;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('agent_workflow_dependency');
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ export { WorkflowStatisticsRepository } from './workflow-statistics.repository';
export { WorkflowTagMappingRepository } from './workflow-tag-mapping.repository';
export { SharedWorkflowRepository } from './shared-workflow.repository';
export { SharedCredentialsRepository } from './shared-credentials.repository';
export { WorkflowRepository } from './workflow.repository';
export { WorkflowRepository, agentToolReferenceWhere } from './workflow.repository';
export { WorkflowPublicationOutboxRepository } from './workflow-publication-outbox.repository';
export {
WorkflowPublicationTriggerStatusRepository,
@@ -72,6 +72,26 @@ type WorkflowListResult = {
count: number;
};
/**
* The workflows an agent's workflow tools refer to: refs by id, legacy refs by
* name, both inside the agent's project. Shared by the runtime lookup and the
* agent dependency index, so the two cannot resolve a reference differently.
*/
export function agentToolReferenceWhere(
projectId: string,
workflowIds: string[],
legacyWorkflowNames: string[],
): Array<FindOptionsWhere<WorkflowEntity>> {
const where: Array<FindOptionsWhere<WorkflowEntity>> = [];
if (workflowIds.length > 0) {
where.push({ id: In(workflowIds), shared: { projectId } });
}
if (legacyWorkflowNames.length > 0) {
where.push({ name: In(legacyWorkflowNames), shared: { projectId } });
}
return where;
}
@Service()
export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
constructor(
@@ -323,13 +343,7 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
workflowIds: string[],
legacyWorkflowNames: string[],
) {
const where: Array<FindOptionsWhere<WorkflowEntity>> = [];
if (workflowIds.length > 0) {
where.push({ id: In(workflowIds), shared: { projectId } });
}
if (legacyWorkflowNames.length > 0) {
where.push({ name: In(legacyWorkflowNames), shared: { projectId } });
}
const where = agentToolReferenceWhere(projectId, workflowIds, legacyWorkflowNames);
if (where.length === 0) return [];
return await this.find({
@@ -0,0 +1,78 @@
/* 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 { EventService } from '@/events/event.service';
import { AgentDependencyIndexListener } from '../agent-dependency-index.listener';
import { AgentDependencyIndexService } from '../agent-dependency-index.service';
import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service';
import type { AgentCredentialDependencyRepository } from '../repositories/agent-credential-dependency.repository';
import type { AgentWorkflowDependencyRepository } from '../repositories/agent-workflow-dependency.repository';
import type { AgentRepository } from '../repositories/agent.repository';
describe('AgentDependencyIndexListener', () => {
const eventService = new EventService();
const workflowDependencyRepository = mock<AgentWorkflowDependencyRepository>();
const runtimeCache = mock<AgentRuntimeCacheService>();
const logger = mock<Logger>();
logger.scoped.mockReturnValue(logger);
const indexService = new AgentDependencyIndexService(
mock<AgentCredentialDependencyRepository>(),
workflowDependencyRepository,
mock<AgentRepository>(),
runtimeCache,
logger,
mock<WorkflowsConfig>({ indexingBatchSize: 2 }),
);
new AgentDependencyIndexListener(logger, eventService, indexService).init();
const workflowEvents = [
'workflow-saved',
'workflow-activated',
'workflow-deactivated',
'workflow-archived',
'workflow-unarchived',
] as const;
const emitWorkflowEvent = (eventName: (typeof workflowEvents)[number]) => {
const payload =
eventName === 'workflow-saved' ? { workflow: { id: 'wf-1' } } : { workflowId: 'wf-1' };
// The listener reads only the workflow id; the rest of each payload is irrelevant here.
eventService.emit(eventName, payload as never);
};
beforeEach(() => {
vi.clearAllMocks();
});
it.each(workflowEvents)(
'clears the runtimes of every dependent agent on %s',
async (eventName) => {
workflowDependencyRepository.findByWorkflowIds.mockResolvedValue([
{ agentId: 'agent-a', workflowId: 'wf-1' },
{ agentId: 'agent-b', workflowId: 'wf-1' },
]);
emitWorkflowEvent(eventName);
await new Promise(setImmediate);
expect(workflowDependencyRepository.findByWorkflowIds).toHaveBeenCalledWith(['wf-1']);
expect(runtimeCache.clearRuntimes).toHaveBeenCalledWith('agent-a');
expect(runtimeCache.clearRuntimes).toHaveBeenCalledWith('agent-b');
},
);
it('logs and swallows a failing dependency lookup', async () => {
workflowDependencyRepository.findByWorkflowIds.mockRejectedValue(new Error('db down'));
emitWorkflowEvent('workflow-activated');
await new Promise(setImmediate);
expect(logger.error).toHaveBeenCalledWith('Failed to invalidate dependent agent runtimes', {
error: expect.any(Error),
});
expect(runtimeCache.clearRuntimes).not.toHaveBeenCalled();
});
});
@@ -3,46 +3,53 @@ 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 { AgentDependencyIndexService } from '../agent-dependency-index.service';
import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service';
import type { AgentCredentialDependencyRepository } from '../repositories/agent-credential-dependency.repository';
import type { AgentWorkflowDependencyRepository } from '../repositories/agent-workflow-dependency.repository';
import type { AgentRepository } from '../repositories/agent.repository';
function makeService(batchSize = 2) {
const dependencyRepository = mock<AgentCredentialDependencyRepository>();
const workflowDependencyRepository = mock<AgentWorkflowDependencyRepository>();
const agentRepository = mock<AgentRepository>();
const workflowsConfig = mock<WorkflowsConfig>({ indexingBatchSize: batchSize });
const logger = mock<Logger>();
logger.scoped.mockReturnValue(logger);
const service = new AgentCredentialIndexService(
const service = new AgentDependencyIndexService(
dependencyRepository,
workflowDependencyRepository,
agentRepository,
mock<AgentRuntimeCacheService>(),
logger,
workflowsConfig,
);
return { service, dependencyRepository, agentRepository, logger };
return { service, dependencyRepository, workflowDependencyRepository, agentRepository, logger };
}
describe('AgentCredentialIndexService', () => {
describe('AgentDependencyIndexService', () => {
it('refreshes both sources from current persisted Agent state', async () => {
const { service, dependencyRepository } = makeService();
const { service, dependencyRepository, workflowDependencyRepository } = makeService();
await service.refresh('agent-1');
expect(dependencyRepository.refreshForAgent).toHaveBeenCalledWith('agent-1');
expect(workflowDependencyRepository.refreshForAgent).toHaveBeenCalledWith('agent-1');
});
it('removes all rows as an idempotent fallback when an agent is deleted', async () => {
const { service, dependencyRepository } = makeService();
const { service, dependencyRepository, workflowDependencyRepository } = makeService();
await service.remove('agent-1');
expect(dependencyRepository.removeForAgent).toHaveBeenCalledWith('agent-1');
expect(workflowDependencyRepository.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
agentRepository.findDependencyIndexAgentIdsBatch
.mockResolvedValueOnce([{ id: 'agent-a' }, { id: 'agent-b' }])
.mockResolvedValueOnce([{ id: 'agent-c' }]);
dependencyRepository.refreshForAgent.mockImplementation(async (agentId) => {
@@ -51,15 +58,15 @@ describe('AgentCredentialIndexService', () => {
await service.buildIndex();
expect(agentRepository.findCredentialIndexAgentIdsBatch).toHaveBeenNthCalledWith(1, null, 2);
expect(agentRepository.findCredentialIndexAgentIdsBatch).toHaveBeenNthCalledWith(
expect(agentRepository.findDependencyIndexAgentIdsBatch).toHaveBeenNthCalledWith(1, null, 2);
expect(agentRepository.findDependencyIndexAgentIdsBatch).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', {
expect(logger.error).toHaveBeenCalledWith('Failed to index agent dependencies', {
agentId: 'agent-b',
error: expect.any(Error),
});
@@ -0,0 +1,144 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { type OperationContext, type TransactionRunner, WorkflowEntity } from '@n8n/db';
import { type DataSource, In, type SelectQueryBuilder } from '@n8n/typeorm';
import { mock, type MockProxy } from 'vitest-mock-extended';
import { mockEntityManager } from '@test/mocking';
import type { AgentHistory } from '../entities/agent-history.entity';
import { AgentWorkflowDependency } from '../entities/agent-workflow-dependency.entity';
import { Agent } from '../entities/agent.entity';
import { AgentWorkflowDependencyRepository } from '../repositories/agent-workflow-dependency.repository';
const entityManager = mockEntityManager(AgentWorkflowDependency);
const mockDataSource = { manager: entityManager };
const makeAgent = (): Agent =>
({
id: 'agent-1',
projectId: 'project-1',
schema: {
name: 'Tool Agent',
model: 'openai/gpt-4.1-mini',
instructions: 'Help the user',
tools: [
{ type: 'workflow', workflow: 'Draft WF', workflowId: 'wf-draft' },
{ type: 'workflow', workflow: 'Legacy WF' },
],
},
activeVersionId: 'published-version-1',
}) as Agent;
const makePublishedVersion = (): AgentHistory =>
({
versionId: 'published-version-1',
agentId: 'agent-1',
schema: {
name: 'Tool Agent',
model: 'openai/gpt-4.1-mini',
instructions: 'Help the user',
tools: [{ type: 'workflow', workflow: 'Published WF', workflowId: 'wf-published' }],
},
}) as unknown as AgentHistory;
const workflows = (...ids: string[]) => ids.map((id) => mock<WorkflowEntity>({ id }));
describe('AgentWorkflowDependencyRepository', () => {
let repository: AgentWorkflowDependencyRepository;
let txRunner: MockProxy<TransactionRunner>;
let lockQuery: MockProxy<SelectQueryBuilder<WorkflowEntity>>;
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),
);
lockQuery = mock<SelectQueryBuilder<WorkflowEntity>>();
lockQuery.select.mockReturnValue(lockQuery);
lockQuery.where.mockReturnValue(lockQuery);
lockQuery.orderBy.mockReturnValue(lockQuery);
lockQuery.setLock.mockReturnValue(lockQuery);
entityManager.createQueryBuilder.mockReturnValue(lockQuery);
repository = new AgentWorkflowDependencyRepository(
mockDataSource as unknown as DataSource,
txRunner,
);
});
it('rebuilds the union of draft and published workflow references, by id and by legacy name', async () => {
const agent = makeAgent();
entityManager.findOne.mockImplementation(async (entity) => {
if (entity === Agent) return agent;
return makePublishedVersion();
});
entityManager.find.mockResolvedValue(workflows('wf-draft', 'wf-legacy', 'wf-published'));
await repository.refreshForAgent(agent.id);
expect(entityManager.find).toHaveBeenCalledWith(WorkflowEntity, {
where: [
{ id: In(['wf-draft', 'wf-published']), shared: { projectId: 'project-1' } },
{ name: In(['Legacy WF']), shared: { projectId: 'project-1' } },
],
select: ['id'],
});
expect(entityManager.delete).toHaveBeenCalledWith(AgentWorkflowDependency, {
agentId: agent.id,
});
expect(entityManager.insert).toHaveBeenCalledWith(AgentWorkflowDependency, [
{ agentId: agent.id, workflowId: 'wf-draft' },
{ agentId: agent.id, workflowId: 'wf-legacy' },
{ agentId: agent.id, workflowId: 'wf-published' },
]);
});
it('only inserts rows for workflows that resolve inside the agent project', async () => {
const agent = makeAgent();
agent.activeVersionId = null;
entityManager.findOne.mockResolvedValue(agent);
entityManager.find.mockResolvedValue(workflows('wf-draft'));
await repository.refreshForAgent(agent.id);
expect(entityManager.insert).toHaveBeenCalledWith(AgentWorkflowDependency, [
{ agentId: agent.id, workflowId: 'wf-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(AgentWorkflowDependency, {
agentId: 'deleted-agent',
});
expect(entityManager.find).not.toHaveBeenCalled();
expect(entityManager.insert).not.toHaveBeenCalled();
});
it('locks the agent and the resolved workflows before replacing rows on Postgres', async () => {
Object.assign(entityManager.connection, { options: { type: 'postgres' } });
const agent = makeAgent();
agent.activeVersionId = null;
entityManager.findOne.mockResolvedValue(agent);
entityManager.find.mockResolvedValue(workflows('wf-draft'));
lockQuery.getMany.mockResolvedValue(workflows('wf-draft'));
await repository.refreshForAgent(agent.id);
expect(entityManager.findOne).toHaveBeenCalledWith(Agent, {
where: { id: agent.id },
lock: { mode: 'pessimistic_write' },
});
expect(lockQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
expect(lockQuery.getMany.mock.invocationCallOrder[0]).toBeLessThan(
entityManager.delete.mock.invocationCallOrder[0],
);
expect(entityManager.insert).toHaveBeenCalledWith(AgentWorkflowDependency, [
{ agentId: agent.id, workflowId: 'wf-draft' },
]);
});
});
@@ -178,8 +178,8 @@ export class AgentConfigService {
validatedConfig.tools,
new Set(accessibleCredentials.map((credential) => credential.type)),
);
await normalizeWorkflowToolRefs(this.workflowRepository, validatedConfig.tools, projectId);
}
await normalizeWorkflowToolRefs(this.workflowRepository, validatedConfig, projectId);
const tasksProvided = validatedConfig.tasks !== undefined;
const existingTaskIds = tasksProvided
@@ -1,43 +0,0 @@
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 });
}
}
}
@@ -1,55 +0,0 @@
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;
}
}
}
@@ -0,0 +1,66 @@
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { EventService } from '@/events/event.service';
import { AgentDependencyIndexService } from './agent-dependency-index.service';
@Service()
export class AgentDependencyIndexListener {
constructor(
private readonly logger: Logger,
private readonly eventService: EventService,
private readonly indexService: AgentDependencyIndexService,
) {
this.logger = this.logger.scoped('agents');
}
init(): void {
this.eventService.on('server-started', async () => {
await this.run('build dependency index', async () => {
await this.indexService.buildIndex();
});
});
this.eventService.on('agent-saved', async ({ agentId }) => {
await this.run('refresh dependency index', async () => {
await this.indexService.refresh(agentId);
});
});
this.eventService.on('agent-deleted', async ({ agentId }) => {
await this.run('remove dependency index entries', async () => {
await this.indexService.remove(agentId);
});
});
this.subscribeToWorkflowEvents();
}
/**
* Any workflow change invalidates the cached runtimes of the agents that depend on it.
* `workflow-deleted` is not subscribed: the FK cascade removes the index rows
* before the event fires. Deleting requires archiving first, which is covered.
* With the publication service, a runtime rebuilt after the request event but before
* the leader applies it can stay stale until its TTL expires or the agent is saved.
*/
private subscribeToWorkflowEvents(): void {
const invalidate = async ({ workflowId }: { workflowId: string }) => {
await this.run('invalidate dependent agent runtimes', async () => {
await this.indexService.invalidateRuntimesForWorkflow(workflowId);
});
};
this.eventService.on('workflow-saved', async ({ workflow }) => {
await invalidate({ workflowId: workflow.id });
});
this.eventService.on('workflow-activated', invalidate);
this.eventService.on('workflow-deactivated', invalidate);
this.eventService.on('workflow-archived', invalidate);
this.eventService.on('workflow-unarchived', invalidate);
}
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,70 @@
import { Logger } from '@n8n/backend-common';
import { WorkflowsConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { AgentRuntimeCacheService } from './agent-runtime-cache.service';
import { AgentCredentialDependencyRepository } from './repositories/agent-credential-dependency.repository';
import { AgentWorkflowDependencyRepository } from './repositories/agent-workflow-dependency.repository';
import { AgentRepository } from './repositories/agent.repository';
@Service()
export class AgentDependencyIndexService {
private readonly batchSize: number;
private readonly logger: Logger;
constructor(
private readonly credentialDependencyRepository: AgentCredentialDependencyRepository,
private readonly workflowDependencyRepository: AgentWorkflowDependencyRepository,
private readonly agentRepository: AgentRepository,
private readonly runtimeCache: AgentRuntimeCacheService,
logger: Logger,
workflowsConfig: WorkflowsConfig,
) {
this.logger = logger.scoped('agents');
this.batchSize = workflowsConfig.indexingBatchSize;
}
async remove(agentId: string): Promise<void> {
await this.credentialDependencyRepository.removeForAgent(agentId);
await this.workflowDependencyRepository.removeForAgent(agentId);
}
async refresh(agentId: string): Promise<void> {
await this.credentialDependencyRepository.refreshForAgent(agentId);
await this.workflowDependencyRepository.refreshForAgent(agentId);
}
/**
* Evict the cached runtimes of every agent that uses the workflow as a tool,
* so the next call rebuilds the tool from the workflow's current state.
*/
async invalidateRuntimesForWorkflow(workflowId: string): Promise<void> {
const dependencies = await this.workflowDependencyRepository.findByWorkflowIds([workflowId]);
for (const { agentId } of dependencies) this.runtimeCache.clearRuntimes(agentId);
}
async buildIndex(): Promise<void> {
let afterId: string | null = null;
while (true) {
const agents = await this.agentRepository.findDependencyIndexAgentIdsBatch(
afterId,
this.batchSize,
);
for (const { id: agentId } of agents) {
try {
await this.refresh(agentId);
} catch (error) {
this.logger.error('Failed to index agent dependencies', { agentId, error });
}
}
if (agents.length < this.batchSize) return;
const lastAgent = agents[agents.length - 1];
if (lastAgent === undefined) return;
afterId = lastAgent.id;
}
}
}
@@ -37,6 +37,7 @@ import { AgentTaskSnapshotRepository } from './repositories/agent-task-snapshot.
import { AgentTaskRepository } from './repositories/agent-task.repository';
import { AgentRepository } from './repositories/agent.repository';
import { findWorkflowToolWorkflows } from './tools/workflow-tool-workflow-resolver';
import { extractAgentWorkflowRefs } from './utils/extract-agent-workflow-refs';
import { findHttpRequestToolUrlFromAiViolations } from './utils/node-tool-validation';
type AgentValidationScope = 'runtime' | 'publish';
@@ -301,7 +302,7 @@ export class AgentValidationService {
workflowsByReference: Map<string, WorkflowEntity>;
}> {
const subAgentIds = new Set<string>();
const workflowRefs: AgentJsonWorkflowToolConfig[] = [];
const workflowRefs = extractAgentWorkflowRefs(ctx.config).filter((ref) => ref.workflow);
for (const ref of ctx.config.subAgents?.agents ?? []) {
if (ref.agentId && ref.agentId !== ctx.agentId) {
@@ -309,12 +310,6 @@ export class AgentValidationService {
}
}
for (const tool of ctx.config.tools ?? []) {
if (tool.type === 'workflow' && tool.workflow) {
workflowRefs.push(tool);
}
}
const [agents, workflowsByReference] = await Promise.all([
this.agentRepository.findByIdsAndProjectId([...subAgentIds], ctx.projectId),
findWorkflowToolWorkflows(this.workflowRepository, workflowRefs, ctx.projectId),
@@ -35,8 +35,8 @@ 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 { AgentDependencyIndexListener } = await import('./agent-dependency-index.listener.js');
Container.get(AgentDependencyIndexListener).init();
const { AgentExecutionService } = await import('./agent-execution.service.js');
Container.get(AgentExecutionService);
@@ -62,6 +62,9 @@ export class AgentsModule implements ModuleInterface {
const { registerFavoriteResolver } = await import('./register-favorite-resolver.js');
registerFavoriteResolver();
const { registerAgentUsageProvider } = await import('./register-agent-usage-provider.js');
registerAgentUsageProvider();
const { AgentRuntimeCacheService } = await import('./agent-runtime-cache.service.js');
Container.get(AgentRuntimeCacheService);
@@ -195,6 +198,9 @@ export class AgentsModule implements ModuleInterface {
const { AgentCredentialDependency } = await import(
'./entities/agent-credential-dependency.entity.js'
);
const { AgentWorkflowDependency } = await import(
'./entities/agent-workflow-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');
@@ -231,6 +237,7 @@ export class AgentsModule implements ModuleInterface {
AgentBackgroundJob,
AgentHistory,
AgentCredentialDependency,
AgentWorkflowDependency,
AgentTask,
AgentTaskRunLock,
AgentTaskSnapshot,
@@ -0,0 +1,22 @@
import { type WorkflowEntity, WithCreatedAt } from '@n8n/db';
import { Entity, Index, JoinColumn, ManyToOne, PrimaryColumn, type Relation } from '@n8n/typeorm';
import type { Agent } from './agent.entity';
@Entity({ name: 'agent_workflow_dependency' })
export class AgentWorkflowDependency 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 })
workflowId: string;
@ManyToOne('WorkflowEntity', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'workflowId' })
workflow: Relation<WorkflowEntity>;
}
@@ -0,0 +1,36 @@
import { Container } from '@n8n/di';
import { AgentUsageProviderProxy } from '@/modules/workflow-index/agent-usage-provider-proxy.service';
import { AgentCredentialDependencyRepository } from './repositories/agent-credential-dependency.repository';
import { AgentWorkflowDependencyRepository } from './repositories/agent-workflow-dependency.repository';
import { AgentRepository } from './repositories/agent.repository';
export function registerAgentUsageProvider(): void {
const credentialDependencyRepository = Container.get(AgentCredentialDependencyRepository);
const workflowDependencyRepository = Container.get(AgentWorkflowDependencyRepository);
const agentRepository = Container.get(AgentRepository);
Container.get(AgentUsageProviderProxy).registerProvider({
async findAgentUsages(resourceType, resourceIds) {
if (resourceType === 'credential') {
const dependencies = await credentialDependencyRepository.findByCredentialIds(resourceIds);
return dependencies.map(({ agentId, credentialId }) => ({
agentId,
resourceId: credentialId,
}));
}
if (resourceType === 'workflow') {
const dependencies = await workflowDependencyRepository.findByWorkflowIds(resourceIds);
return dependencies.map(({ agentId, workflowId }) => ({
agentId,
resourceId: workflowId,
}));
}
return [];
},
findAgentSummaries: async (agentIds) => await agentRepository.findSummariesByIds(agentIds),
});
}
@@ -0,0 +1,131 @@
import {
agentToolReferenceWhere,
BaseRepository,
TransactionRunner,
WorkflowEntity,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { DataSource, In, type EntityManager } from '@n8n/typeorm';
import { AgentHistory } from '../entities/agent-history.entity';
import { AgentWorkflowDependency } from '../entities/agent-workflow-dependency.entity';
import { Agent } from '../entities/agent.entity';
import { extractAgentWorkflowRefs } from '../utils/extract-agent-workflow-refs';
export type AgentWorkflowDependencyReference = Pick<
AgentWorkflowDependency,
'agentId' | 'workflowId'
>;
@Service()
export class AgentWorkflowDependencyRepository extends BaseRepository<AgentWorkflowDependency> {
constructor(
dataSource: DataSource,
private readonly txRunner: TransactionRunner,
) {
super(AgentWorkflowDependency, dataSource.manager, txRunner);
}
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(AgentWorkflowDependency, { 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(AgentWorkflowDependency, { agentId });
return;
}
const publishedVersion =
agent.activeVersionId === null
? null
: await manager.findOne(AgentHistory, {
where: { versionId: agent.activeVersionId, agentId },
});
const refs = [
...extractAgentWorkflowRefs(agent.schema),
...extractAgentWorkflowRefs(publishedVersion?.schema),
];
const workflowIds = [
...new Set(refs.flatMap((ref) => (ref.workflowId === undefined ? [] : [ref.workflowId]))),
];
const legacyWorkflowNames = [
...new Set(refs.flatMap((ref) => (ref.workflowId === undefined ? [ref.workflow] : []))),
];
const resolvedWorkflowIds = await this.resolveWorkflowIds(
manager,
agent.projectId,
workflowIds,
legacyWorkflowNames,
);
const existingWorkflowIds = isPostgres
? await this.lockWorkflows(manager, resolvedWorkflowIds)
: resolvedWorkflowIds;
if (isPostgres) {
await manager.delete(AgentWorkflowDependency, { agentId });
}
const rows = [...existingWorkflowIds].map((workflowId) => ({ agentId, workflowId }));
if (rows.length > 0) {
await manager.insert(AgentWorkflowDependency, rows);
}
});
}
async removeForAgent(agentId: string): Promise<void> {
await this.managerFor({}).delete(AgentWorkflowDependency, { agentId });
}
async findByWorkflowIds(workflowIds: string[]): Promise<AgentWorkflowDependencyReference[]> {
if (workflowIds.length === 0) return [];
return await this.find({
select: ['agentId', 'workflowId'],
where: { workflowId: In(workflowIds) },
});
}
private async resolveWorkflowIds(
manager: EntityManager,
projectId: string,
workflowIds: string[],
legacyWorkflowNames: string[],
): Promise<Set<string>> {
const where = agentToolReferenceWhere(projectId, workflowIds, legacyWorkflowNames);
if (where.length === 0) return new Set();
const workflows = await manager.find(WorkflowEntity, { where, select: ['id'] });
return new Set(workflows.map(({ id }) => id));
}
private async lockWorkflows(
manager: EntityManager,
workflowIds: ReadonlySet<string>,
): Promise<Set<string>> {
if (workflowIds.size === 0) return new Set();
const query = manager
.createQueryBuilder(WorkflowEntity, 'workflow')
.select(['workflow.id'])
.where('workflow.id IN (:...workflowIds)', { workflowIds: [...workflowIds] })
.orderBy('workflow.id', 'ASC')
.setLock('pessimistic_write');
return new Set((await query.getMany()).map(({ id }) => id));
}
}
@@ -170,7 +170,7 @@ export class AgentRepository extends Repository<Agent> {
});
}
async findCredentialIndexAgentIdsBatch(
async findDependencyIndexAgentIdsBatch(
afterId: string | null,
batchSize: number,
): Promise<Array<Pick<Agent, 'id'>>> {
@@ -911,13 +911,6 @@ export async function resolveWorkflowTool(
* shares the real tool's handler, which reloads and re-validates the workflow on
* every call: the model gets the current reason instead of a bare "tool not found",
* and a workflow the user has fixed since works on the next call.
*
* Interim until AGENT-790: the runtime cache keeps this stub alive for up to 30 idle
* minutes because nothing invalidates an agent runtime when one of its workflows
* changes. Until the runtime is rebuilt the model only sees a free-form input
* schema, not the workflow's declared inputs. Once AGENT-790 invalidates the
* runtimes of dependent agents on workflow changes, a rebuilt runtime carries the
* real tool and this stub lives only while the workflow is actually broken.
*/
export function buildUnavailableWorkflowTool(
descriptor: Extract<AgentJsonToolConfig, { type: 'workflow' }>,
@@ -1,10 +1,8 @@
import type {
AgentJsonConfig,
AgentJsonToolConfig,
AgentJsonWorkflowToolConfig,
} from '@n8n/api-types';
import type { AgentJsonConfig, AgentJsonWorkflowToolConfig } from '@n8n/api-types';
import type { WorkflowEntity, WorkflowRepository } from '@n8n/db';
import { extractAgentWorkflowRefs } from '../utils/extract-agent-workflow-refs';
export async function findWorkflowToolWorkflows(
workflowRepository: WorkflowRepository,
refs: AgentJsonWorkflowToolConfig[],
@@ -54,13 +52,10 @@ export async function findWorkflowToolWorkflow(
*/
export async function normalizeWorkflowToolRefs(
workflowRepository: WorkflowRepository,
tools: AgentJsonConfig['tools'],
config: AgentJsonConfig,
projectId: string,
): Promise<void> {
const refs = (tools ?? []).filter(
(tool): tool is Extract<AgentJsonToolConfig, { type: 'workflow' }> => tool.type === 'workflow',
);
const legacyRefs = refs.filter((ref) => ref.workflowId === undefined);
const legacyRefs = extractAgentWorkflowRefs(config).filter((ref) => ref.workflowId === undefined);
if (legacyRefs.length === 0) return;
const values = [...new Set(legacyRefs.map((ref) => ref.workflow))];
@@ -0,0 +1,14 @@
import type { AgentJsonConfig, AgentJsonWorkflowToolConfig } from '@n8n/api-types';
/**
* Every workflow reference in an agent config. Only workflow tools carry one
* today. Add a new location here and the runtime, the validation and the
* dependency index pick it up together.
*/
export function extractAgentWorkflowRefs(
config: AgentJsonConfig | null | undefined,
): AgentJsonWorkflowToolConfig[] {
return (config?.tools ?? []).filter(
(tool): tool is AgentJsonWorkflowToolConfig => tool.type === 'workflow',
);
}
@@ -0,0 +1,35 @@
import type { DependencyResourceType } from '@n8n/api-types';
import { Service } from '@n8n/di';
export interface AgentUsageProvider {
findAgentUsages(
resourceType: DependencyResourceType,
resourceIds: string[],
): Promise<Array<{ agentId: string; resourceId: string }>>;
findAgentSummaries(
agentIds: string[],
): Promise<Array<{ id: string; name: string; projectId: string }>>;
}
@Service()
export class AgentUsageProviderProxy implements AgentUsageProvider {
private provider: AgentUsageProvider | null = null;
registerProvider(provider: AgentUsageProvider): void {
this.provider = provider;
}
async findAgentUsages(
resourceType: DependencyResourceType,
resourceIds: string[],
): Promise<Array<{ agentId: string; resourceId: string }>> {
return (await this.provider?.findAgentUsages(resourceType, resourceIds)) ?? [];
}
async findAgentSummaries(
agentIds: string[],
): Promise<Array<{ id: string; name: string; projectId: string }>> {
return (await this.provider?.findAgentSummaries(agentIds)) ?? [];
}
}
@@ -4,7 +4,6 @@ import type {
DependencyResourceType,
ResolvedDependency,
} from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import {
CredentialsRepository,
ProjectRelationRepository,
@@ -17,12 +16,12 @@ 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';
import { AgentUsageProviderProxy } from './agent-usage-provider-proxy.service';
/** Workflows named for a node type when the caller does not say how many it wants. */
const DEFAULT_NODE_USAGE_WORKFLOW_LIMIT = 10;
@@ -74,9 +73,7 @@ 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,
private readonly agentUsageProvider: AgentUsageProviderProxy,
) {}
/**
@@ -197,7 +194,7 @@ export class WorkflowDependencyQueryService {
})
: [],
maps.allAgentIds.size > 0
? this.agentRepository.findSummariesByIds([...maps.allAgentIds])
? this.agentUsageProvider.findAgentSummaries([...maps.allAgentIds])
: [],
]);
@@ -270,9 +267,7 @@ export class WorkflowDependencyQueryService {
],
select: ['workflowId', 'dependencyType', 'dependencyKey'],
}),
resourceType === 'credential' && this.moduleRegistry.isActive('agents')
? this.agentDependencyRepository.findByCredentialIds(accessibleInputIds)
: [],
this.loadAgentDeps(resourceType, accessibleInputIds),
]);
if (rawDeps.length === 0 && agentDeps.length === 0) return null;
@@ -280,9 +275,17 @@ export class WorkflowDependencyQueryService {
return { accessibleInputIds, maps: this.buildDepMaps(rawDeps, agentDeps) };
}
/** Agents using the resources, as `{ agentId, resourceId }` regardless of resource type. */
private async loadAgentDeps(
resourceType: DependencyResourceType,
resourceIds: string[],
): Promise<Array<{ agentId: string; resourceId: string }>> {
return await this.agentUsageProvider.findAgentUsages(resourceType, resourceIds);
}
private buildDepMaps(
rawDeps: Array<{ workflowId: string; dependencyType: string; dependencyKey: string }>,
agentDeps: Array<{ agentId: string; credentialId: string }>,
agentDeps: Array<{ agentId: string; resourceId: string }>,
): RawDepMaps {
const agentUsageMap = new Map<string, Set<string>>();
const credMap = new Map<string, Set<string>>();
@@ -323,7 +326,7 @@ export class WorkflowDependencyQueryService {
}
for (const dep of agentDeps) {
addToSet(agentUsageMap, dep.credentialId, dep.agentId);
addToSet(agentUsageMap, dep.resourceId, dep.agentId);
allAgentIds.add(dep.agentId);
}
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import type { AgentJsonConfig } from '@n8n/api-types';
import {
createWorkflow,
randomCredentialPayload,
@@ -11,10 +12,13 @@ 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 { AgentWorkflowDependency } from '@/modules/agents/entities/agent-workflow-dependency.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 { AgentWorkflowDependencyRepository } from '@/modules/agents/repositories/agent-workflow-dependency.repository';
import { AgentRepository } from '@/modules/agents/repositories/agent.repository';
import { registerAgentUsageProvider } from '@/modules/agents/register-agent-usage-provider';
import { saveCredential } from '../shared/db/credentials';
import { createMember, createOwner } from '../shared/db/users';
@@ -23,14 +27,19 @@ import * as utils from '../shared/utils';
let testServer: ReturnType<typeof utils.setupTestServer>;
let depRepo: WorkflowDependencyRepository;
let agentDepRepo: AgentCredentialDependencyRepository;
let agentWorkflowDepRepo: AgentWorkflowDependencyRepository;
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');
moduleRegistry.entities.push(
Agent,
AgentHistory,
AgentCredentialDependency,
AgentWorkflowDependency,
);
});
testServer = utils.setupTestServer({
@@ -41,9 +50,11 @@ testServer = utils.setupTestServer({
beforeAll(() => {
depRepo = Container.get(WorkflowDependencyRepository);
agentDepRepo = Container.get(AgentCredentialDependencyRepository);
agentWorkflowDepRepo = Container.get(AgentWorkflowDependencyRepository);
agentHistoryRepo = Container.get(AgentHistoryRepository);
agentRepo = Container.get(AgentRepository);
projectRepo = Container.get(ProjectRepository);
registerAgentUsageProvider();
});
/** Seed a workflow_dependency row (draft). */
@@ -61,10 +72,11 @@ async function seedDep(workflowId: string, dependencyType: string, dependencyKey
);
}
async function seedAgentCredentialDependencies(
/** Seed a published agent whose draft and published config share `schema`, then index it. */
async function seedIndexedAgent(
user: User,
credentialId: string,
name = 'Support Agent',
name: string,
schema: Pick<AgentJsonConfig, 'credential' | 'tools'>,
) {
const project = await projectRepo.getPersonalProjectForUserOrFail(user.id);
const agent = agentRepo.create({
@@ -74,9 +86,8 @@ async function seedAgentCredentialDependencies(
name,
model: 'openai/gpt-4.1-mini',
instructions: 'Help the user',
credential: credentialId,
tools: [],
skills: [],
...schema,
},
integrations: [],
tools: {},
@@ -97,6 +108,7 @@ async function seedAgentCredentialDependencies(
agent.activeVersionId = publishedVersionId;
await agentRepo.save(agent);
await agentDepRepo.refreshForAgent(agent.id);
await agentWorkflowDepRepo.refreshForAgent(agent.id);
return agent;
}
@@ -167,7 +179,7 @@ describe('POST /workflow-dependencies/counts', () => {
user: owner,
role: 'credential:owner',
});
await seedAgentCredentialDependencies(owner, credential.id);
await seedIndexedAgent(owner, 'Support Agent', { credential: credential.id, tools: [] });
const resp = await testServer
.authAgentFor(owner)
@@ -388,7 +400,10 @@ describe('POST /workflow-dependencies/details', () => {
user: owner,
role: 'credential:owner',
});
const agent = await seedAgentCredentialDependencies(owner, credential.id, 'Support Agent');
const agent = await seedIndexedAgent(owner, 'Support Agent', {
credential: credential.id,
tools: [],
});
const resp = await testServer
.authAgentFor(owner)
@@ -409,6 +424,42 @@ describe('POST /workflow-dependencies/details', () => {
});
});
it('should resolve an accessible agent using a workflow by id and by legacy name', async () => {
const owner = await createOwner();
const wfA = await createWorkflow({ name: 'Tool A' }, owner);
const wfB = await createWorkflow({ name: 'Tool B' }, owner);
const agent = await seedIndexedAgent(owner, 'Tool Agent', {
tools: [
{ type: 'workflow', workflow: 'Tool A', workflowId: wfA.id },
{ type: 'workflow', workflow: 'Tool B' },
],
});
const counts = await testServer
.authAgentFor(owner)
.post('/workflow-dependencies/counts')
.send({ resourceIds: [wfA.id, wfB.id], resourceType: 'workflow' });
expect(counts.statusCode).toBe(200);
expect(counts.body.data[wfA.id].agentUsage).toBe(1);
expect(counts.body.data[wfB.id].agentUsage).toBe(1);
const details = await testServer
.authAgentFor(owner)
.post('/workflow-dependencies/details')
.send({ resourceIds: [wfA.id, wfB.id], resourceType: 'workflow' });
expect(details.statusCode).toBe(200);
const expected = {
dependencies: [
{ id: agent.id, name: 'Tool Agent', type: 'agentUsage', projectId: agent.projectId },
],
inaccessibleCount: 0,
};
expect(details.body.data[wfA.id]).toEqual(expected);
expect(details.body.data[wfB.id]).toEqual(expected);
});
it('should report an inaccessible agent without exposing its details', async () => {
const owner = await createOwner();
const member = await createMember();
@@ -416,7 +467,7 @@ describe('POST /workflow-dependencies/details', () => {
user: member,
role: 'credential:owner',
});
await seedAgentCredentialDependencies(owner, credential.id, 'Private Agent');
await seedIndexedAgent(owner, 'Private Agent', { credential: credential.id, tools: [] });
const resp = await testServer
.authAgentFor(member)
@@ -4832,6 +4832,7 @@
"workflowHistory.action.unpublish.notAvailable": "This workflow has no published version to unpublish",
"workflowHistory.action.unpublish.modal.title": "Unpublish {versionName}",
"workflowHistory.action.unpublish.modal.description": "This will prevent all production executions to this workflow until you publish again.",
"workflowHistory.action.unpublish.modal.affected": "These resources use this workflow and will stop working until you publish again:",
"workflowHistory.action.unpublish.modal.button.unpublish": "Unpublish",
"workflowHistory.group.unnamedVersions": "1 version | {count} versions",
"workflowHistory.publishModal.title": "Publish {versionName}",
@@ -719,6 +719,7 @@ const onUnpublish = () => {
uiStore.openModalWithData({
name: WORKFLOW_HISTORY_VERSION_UNPUBLISH,
data: {
workflowId: props.id,
versionName: activeVersion.value.name,
eventBus: unpublishEventBus,
},
@@ -486,6 +486,7 @@ async function unpublishWorkflow() {
uiStore.openModalWithData({
name: WORKFLOW_HISTORY_VERSION_UNPUBLISH,
data: {
workflowId: props.data.id,
versionName: props.data.name,
eventBus: unpublishEventBus,
},
@@ -0,0 +1,113 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import { createEventBus } from '@n8n/utils/event-bus';
import { VIEWS, WORKFLOW_HISTORY_VERSION_UNPUBLISH } from '@/app/constants';
import { AGENT_BUILDER_VIEW } from '@/features/agents/constants';
import * as workflowDependenciesApi from '@/app/api/workflow-dependencies';
import WorkflowHistoryVersionUnpublishModal from './WorkflowHistoryVersionUnpublishModal.vue';
vi.mock('@/app/api/workflow-dependencies');
vi.mock('vue-router', () => ({
useRouter: () => ({
resolve: ({ name, params }: { name: string; params: Record<string, string> }) => ({
href: `/${name}/${Object.values(params).join('/')}`,
}),
}),
useRoute: () => ({}),
RouterLink: vi.fn(),
}));
const ModalStub = {
template: '<div><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
};
const renderModal = (workflowId: string) =>
createComponentRenderer(WorkflowHistoryVersionUnpublishModal)({
props: {
modalName: WORKFLOW_HISTORY_VERSION_UNPUBLISH,
data: { workflowId, versionName: 'v3', eventBus: createEventBus() },
},
pinia: createTestingPinia(),
global: { stubs: { Modal: ModalStub } },
});
const noCounts = {
agentUsage: 0,
credentialId: 0,
dataTableId: 0,
errorWorkflow: 0,
errorWorkflowParent: 0,
workflowCall: 0,
workflowParent: 0,
};
describe('WorkflowHistoryVersionUnpublishModal', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('lists the dependents that run the published version and counts the ones the user cannot see', async () => {
vi.mocked(workflowDependenciesApi.getResourceDependencies).mockResolvedValue({
'wf-1': {
dependencies: [
{ type: 'credentialId', id: 'cred-1', name: 'Slack account' },
{ type: 'workflowCall', id: 'sub-1', name: 'Sub-workflow' },
{ type: 'workflowParent', id: 'parent-1', name: 'Order intake' },
{ type: 'errorWorkflowParent', id: 'wf-9', name: 'Billing sync' },
{ type: 'agentUsage', id: 'agent-1', name: 'Support agent', projectId: 'proj-1' },
],
inaccessibleCount: 4,
},
});
vi.mocked(workflowDependenciesApi.getResourceDependencyCounts).mockResolvedValue({
'wf-1': {
...noCounts,
credentialId: 3,
workflowCall: 1,
workflowParent: 2,
errorWorkflowParent: 1,
agentUsage: 2,
},
});
const { findByTestId, getByText, queryByText } = renderModal('wf-1');
const affected = await findByTestId('workflow-unpublish-affected-resources');
expect(getByText('Order intake').closest('a')).toHaveAttribute(
'href',
`/${VIEWS.WORKFLOW}/parent-1`,
);
expect(getByText('Billing sync')).toBeInTheDocument();
expect(getByText('Support agent').closest('a')).toHaveAttribute(
'href',
`/${AGENT_BUILDER_VIEW}/proj-1/agent-1`,
);
// This workflow's own dependencies keep working, so they stay out of the list
expect(queryByText('Slack account')).not.toBeInTheDocument();
expect(queryByText('Sub-workflow')).not.toBeInTheDocument();
// 5 dependents in the counts, 3 visible: the 2 hidden credentials do not count
expect(affected).toHaveTextContent('+2 not accessible to you');
});
it('shows only the generic warning when nothing runs the published version', async () => {
vi.mocked(workflowDependenciesApi.getResourceDependencies).mockResolvedValue({
'wf-2': {
dependencies: [{ type: 'credentialId', id: 'cred-1', name: 'Slack account' }],
inaccessibleCount: 1,
},
});
vi.mocked(workflowDependenciesApi.getResourceDependencyCounts).mockResolvedValue({
'wf-2': { ...noCounts, credentialId: 2 },
});
const { queryByTestId, getByText } = renderModal('wf-2');
await waitFor(() =>
expect(workflowDependenciesApi.getResourceDependencyCounts).toHaveBeenCalled(),
);
expect(getByText(/prevent all production executions/)).toBeInTheDocument();
expect(queryByTestId('workflow-unpublish-affected-resources')).not.toBeInTheDocument();
});
});
@@ -1,29 +1,87 @@
<script lang="ts" setup>
import { ref, onBeforeUnmount } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { DependencyType, ResolvedDependency } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
import Modal from '@/app/components/Modal.vue';
import { useDependencies } from '@/app/composables/useDependencies';
import { VIEWS } from '@/app/constants';
import { useUIStore } from '@/app/stores/ui.store';
import { AGENT_BUILDER_VIEW } from '@/features/agents/constants';
import type { EventBus } from '@n8n/utils/event-bus';
import { N8nButton, N8nHeading, N8nIcon, N8nText } from '@n8n/design-system';
import { N8nButton, N8nHeading, N8nIcon, N8nLink, N8nText } from '@n8n/design-system';
export type WorkflowHistoryVersionUnpublishModalEventBusEvents = {
unpublish: undefined;
cancel: undefined;
};
/** Dependents that run the published version, so they break when it goes away. */
const AFFECTED_TYPES: Array<{ type: DependencyType; labelKey: BaseTextKey }> = [
{ type: 'workflowParent', labelKey: 'workflows.dependencies.type.parentWorkflows' },
{ type: 'errorWorkflowParent', labelKey: 'workflows.dependencies.type.errorWorkflowParent' },
{ type: 'agentUsage', labelKey: 'workflows.dependencies.type.agents' },
];
const props = defineProps<{
modalName: string;
data: {
workflowId: string;
versionName?: string;
eventBus: EventBus<WorkflowHistoryVersionUnpublishModalEventBusEvents>;
};
}>();
const i18n = useI18n();
const router = useRouter();
const uiStore = useUIStore();
const { fetchDependencies, fetchDependencyCounts, getDependencies, getDependencyCounts } =
useDependencies();
const unpublishing = ref(false);
onMounted(async () => {
await Promise.all([
fetchDependencies([props.data.workflowId], 'workflow'),
fetchDependencyCounts([props.data.workflowId], 'workflow'),
]);
});
const hrefFor = (dep: ResolvedDependency): string | undefined => {
if (dep.type === 'agentUsage') {
if (!dep.projectId) return undefined;
return router.resolve({
name: AGENT_BUILDER_VIEW,
params: { projectId: dep.projectId, agentId: dep.id },
}).href;
}
return router.resolve({ name: VIEWS.WORKFLOW, params: { workflowId: dep.id } }).href;
};
const affectedGroups = computed(() => {
const dependencies = getDependencies(props.data.workflowId)?.dependencies ?? [];
return AFFECTED_TYPES.map((group) => ({
...group,
items: dependencies
.filter((dep) => dep.type === group.type)
.map((dep) => ({ ...dep, href: hrefFor(dep) })),
})).filter((group) => group.items.length > 0);
});
// The counts include dependents the user cannot see; the details list does not.
const hiddenAffectedCount = computed(() => {
const counts = getDependencyCounts(props.data.workflowId);
if (!counts) return 0;
const total = AFFECTED_TYPES.reduce((sum, { type }) => sum + counts[type], 0);
const visible = affectedGroups.value.reduce((sum, group) => sum + group.items.length, 0);
return total - visible;
});
const hasAffected = computed(
() => affectedGroups.value.length > 0 || hiddenAffectedCount.value > 0,
);
const closeModal = () => {
uiStore.closeModal(props.modalName);
};
@@ -45,7 +103,7 @@ onBeforeUnmount(() => {
</script>
<template>
<Modal width="500px" :name="props.modalName" :before-close="onCancel">
<Modal width="500px" max-height="85vh" :name="props.modalName" :before-close="onCancel">
<template #header>
<N8nHeading tag="h2" size="xlarge">
{{
@@ -58,13 +116,43 @@ onBeforeUnmount(() => {
<template #content>
<div :class="$style.content">
<N8nIcon :class="$style.icon" icon="triangle-alert" color="warning" size="xlarge" />
<N8nText size="medium">
{{
i18n.baseText('workflowHistory.action.unpublish.modal.description', {
interpolate: { versionName: props.data.versionName || '' },
})
}}
</N8nText>
<div :class="$style.body">
<N8nText size="medium">
{{
i18n.baseText('workflowHistory.action.unpublish.modal.description', {
interpolate: { versionName: props.data.versionName || '' },
})
}}
</N8nText>
<template v-if="hasAffected">
<N8nText size="medium">
{{ i18n.baseText('workflowHistory.action.unpublish.modal.affected') }}
</N8nText>
<div :class="$style.affected" data-test-id="workflow-unpublish-affected-resources">
<div v-for="group in affectedGroups" :key="group.type">
<N8nText size="small" color="text-light" bold>
{{ i18n.baseText(group.labelKey) }}
</N8nText>
<ul :class="$style.list">
<li v-for="dep in group.items" :key="dep.id">
<N8nLink v-if="dep.href" :to="dep.href" new-window size="small">
{{ dep.name }}
</N8nLink>
<N8nText v-else size="small">{{ dep.name }}</N8nText>
</li>
</ul>
</div>
<N8nText v-if="hiddenAffectedCount > 0" size="small" color="text-light">
{{
i18n.baseText('workflows.dependencies.hiddenNotice', {
adjustToNumber: hiddenAffectedCount,
interpolate: { count: String(hiddenAffectedCount) },
})
}}
</N8nText>
</div>
</template>
</div>
</div>
</template>
<template #footer>
@@ -102,4 +190,23 @@ onBeforeUnmount(() => {
flex-shrink: 0;
margin-top: var(--spacing--4xs);
}
.body {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
min-width: 0;
}
.affected {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.list {
margin: 0;
padding-left: var(--spacing--md);
list-style: disc;
}
</style>
@@ -390,6 +390,7 @@ const unpublishWorkflowVersion = (id: WorkflowVersionId, data: WorkflowHistoryAc
uiStore.openModalWithData({
name: WORKFLOW_HISTORY_VERSION_UNPUBLISH,
data: {
workflowId: workflowId.value,
versionName: data.versionName,
eventBus: unpublishEventBus,
},