mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
feat(core): Index agent workflow tool dependencies and invalidate dependent runtimes (#37857)
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Generated
+8
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Generated
+8
@@ -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)
|
||||
Generated
+7
-1
@@ -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
@@ -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
|
||||
|
||||
+23
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+17
-10
@@ -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),
|
||||
});
|
||||
+144
@@ -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}",
|
||||
|
||||
+1
@@ -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,
|
||||
},
|
||||
|
||||
+113
@@ -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();
|
||||
});
|
||||
});
|
||||
+117
-10
@@ -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>
|
||||
|
||||
+1
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user