diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md
index cba3ca39fe9..745f857259f 100644
--- a/docs/generated/postgres-schema/README.md
+++ b/docs/generated/postgres-schema/README.md
@@ -20,7 +20,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.agent_eval_result](public.agent_eval_result.md) | 15 | | BASE TABLE |
| [public.agent_eval_run](public.agent_eval_run.md) | 14 | | BASE TABLE |
| [public.agent_execution](public.agent_execution.md) | 22 | | BASE TABLE |
-| [public.agent_execution_threads](public.agent_execution_threads.md) | 17 | | BASE TABLE |
+| [public.agent_execution_threads](public.agent_execution_threads.md) | 19 | | BASE TABLE |
| [public.agent_files](public.agent_files.md) | 10 | | BASE TABLE |
| [public.agent_history](public.agent_history.md) | 9 | | BASE TABLE |
| [public.agent_task_definition](public.agent_task_definition.md) | 8 | | BASE TABLE |
@@ -189,6 +189,7 @@ erDiagram
"public.agent_eval_run" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.agent_eval_run" }o--|| "public.agent_eval_dataset" : "FOREIGN KEY (#quot;datasetId#quot;) REFERENCES agent_eval_dataset(id) ON DELETE CASCADE"
"public.agent_execution" }o--|| "public.agent_execution_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES agent_execution_threads(id) ON DELETE CASCADE"
+"public.agent_execution_threads" }o--o| "public.user" : "FOREIGN KEY (#quot;ownerId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.agent_execution_threads" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.agent_execution_threads" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_execution_threads" }o--o| "public.agent_history" : "FOREIGN KEY (#quot;taskVersionId#quot;) REFERENCES agent_history(#quot;versionId#quot;) ON DELETE SET NULL"
@@ -537,11 +538,13 @@ erDiagram
text userMessage
}
"public.agent_execution_threads" {
+ varchar_16_ accessScope
varchar_36_ agentId FK
varchar_255_ agentName
timestamp_3__with_time_zone createdAt
varchar_8_ emoji
varchar_128_ id
+ uuid ownerId FK
varchar_36_ parentAgentId
varchar_128_ parentThreadId
varchar_255_ projectId FK
diff --git a/docs/generated/postgres-schema/public.agent_checkpoints.md b/docs/generated/postgres-schema/public.agent_checkpoints.md
index ccfbaa18af6..ee4865a8032 100644
--- a/docs/generated/postgres-schema/public.agent_checkpoints.md
+++ b/docs/generated/postgres-schema/public.agent_checkpoints.md
@@ -29,6 +29,7 @@
| ---- | ---------- |
| IDX_5e31c210f896d539964bf99fe3 | CREATE INDEX "IDX_5e31c210f896d539964bf99fe3" ON public.agent_checkpoints USING btree ("agentId") |
| IDX_agent_checkpoints_thread | CREATE INDEX "IDX_agent_checkpoints_thread" ON public.agent_checkpoints USING btree ("agentId", "threadId", expired, "updatedAt") |
+| IDX_agent_checkpoints_threadId | CREATE INDEX "IDX_agent_checkpoints_threadId" ON public.agent_checkpoints USING btree ("threadId") |
| PK_50a27cbafa6806c9b162304b5fd | CREATE UNIQUE INDEX "PK_50a27cbafa6806c9b162304b5fd" ON public.agent_checkpoints USING btree ("runId") |
## Relations
diff --git a/docs/generated/postgres-schema/public.agent_execution.md b/docs/generated/postgres-schema/public.agent_execution.md
index c8cec9c184d..849c8a86c12 100644
--- a/docs/generated/postgres-schema/public.agent_execution.md
+++ b/docs/generated/postgres-schema/public.agent_execution.md
@@ -84,11 +84,13 @@ erDiagram
text userMessage
}
"public.agent_execution_threads" {
+ varchar_16_ accessScope
varchar_36_ agentId FK
varchar_255_ agentName
timestamp_3__with_time_zone createdAt
varchar_8_ emoji
varchar_128_ id
+ uuid ownerId FK
varchar_36_ parentAgentId
varchar_128_ parentThreadId
varchar_255_ projectId FK
diff --git a/docs/generated/postgres-schema/public.agent_execution_threads.md b/docs/generated/postgres-schema/public.agent_execution_threads.md
index dc2d0cea82e..25ab87fd5a7 100644
--- a/docs/generated/postgres-schema/public.agent_execution_threads.md
+++ b/docs/generated/postgres-schema/public.agent_execution_threads.md
@@ -4,11 +4,13 @@
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
+| accessScope | varchar(16) | 'user'::character varying | false | | | user: private session; project: shared integration, workflow, or task session |
| agentId | varchar(36) | | false | | [public.agents](public.agents.md) | |
| agentName | varchar(255) | | false | | | |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| emoji | varchar(8) | | true | | | |
| id | varchar(128) | | false | [public.agent_execution](public.agent_execution.md) | | |
+| ownerId | uuid | | true | | [public.user](public.user.md) | User who started this private session |
| parentAgentId | varchar(36) | | true | | | Saved agent id of the parent that delegated this subagent run. |
| parentThreadId | varchar(128) | | true | | | Parent session thread id that delegated this subagent run. |
| projectId | varchar(255) | | false | | [public.project](public.project.md) | |
@@ -26,10 +28,13 @@
| Name | Type | Definition |
| ---- | ---- | ---------- |
+| CHK_agent_execution_threads_accessScope | CHECK | CHECK ((("accessScope")::text = ANY ((ARRAY['user'::character varying, 'project'::character varying])::text[]))) |
| FK_0468a9dc35597314e641d4722aa | FOREIGN KEY | FOREIGN KEY ("agentId") REFERENCES agents(id) ON DELETE CASCADE |
| FK_0e2f8bf92a7a9c88b89670f701c | FOREIGN KEY | FOREIGN KEY ("projectId") REFERENCES project(id) ON DELETE CASCADE |
+| FK_agent_execution_threads_owner | FOREIGN KEY | FOREIGN KEY ("ownerId") REFERENCES "user"(id) ON DELETE SET NULL |
| FK_f00b52d74fe11838e1fe086deea | FOREIGN KEY | FOREIGN KEY ("taskVersionId") REFERENCES agent_history("versionId") ON DELETE SET NULL |
| PK_22373dbf6ba6929d8ac50093309 | PRIMARY KEY | PRIMARY KEY (id) |
+| agent_execution_threads_accessScope_not_null | n | NOT NULL "accessScope" |
| agent_execution_threads_agentId_not_null | n | NOT NULL "agentId" |
| agent_execution_threads_agentName_not_null | n | NOT NULL "agentName" |
| agent_execution_threads_createdAt_not_null | n | NOT NULL "createdAt" |
@@ -48,6 +53,7 @@
| ---- | ---------- |
| IDX_0468a9dc35597314e641d4722a | CREATE INDEX "IDX_0468a9dc35597314e641d4722a" ON public.agent_execution_threads USING btree ("agentId") |
| IDX_0e2f8bf92a7a9c88b89670f701 | CREATE INDEX "IDX_0e2f8bf92a7a9c88b89670f701" ON public.agent_execution_threads USING btree ("projectId") |
+| IDX_agent_execution_threads_ownerId | CREATE INDEX "IDX_agent_execution_threads_ownerId" ON public.agent_execution_threads USING btree ("ownerId") |
| IDX_agent_execution_threads_taskVersionId | CREATE INDEX "IDX_agent_execution_threads_taskVersionId" ON public.agent_execution_threads USING btree ("taskVersionId") |
| PK_22373dbf6ba6929d8ac50093309 | CREATE UNIQUE INDEX "PK_22373dbf6ba6929d8ac50093309" ON public.agent_execution_threads USING btree (id) |
@@ -58,15 +64,18 @@ erDiagram
"public.agent_execution_threads" }o--|| "public.agents" : "FOREIGN KEY (#quot;agentId#quot;) REFERENCES agents(id) ON DELETE CASCADE"
"public.agent_execution" }o--|| "public.agent_execution_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES agent_execution_threads(id) ON DELETE CASCADE"
+"public.agent_execution_threads" }o--o| "public.user" : "FOREIGN KEY (#quot;ownerId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.agent_execution_threads" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.agent_execution_threads" }o--o| "public.agent_history" : "FOREIGN KEY (#quot;taskVersionId#quot;) REFERENCES agent_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.agent_execution_threads" {
+ varchar_16_ accessScope
varchar_36_ agentId FK
varchar_255_ agentName
timestamp_3__with_time_zone createdAt
varchar_8_ emoji
varchar_128_ id
+ uuid ownerId FK
varchar_36_ parentAgentId
varchar_128_ parentThreadId
varchar_255_ projectId FK
@@ -120,6 +129,23 @@ erDiagram
timestamp_3__with_time_zone updatedAt
text userMessage
}
+"public.user" {
+ timestamp_3__with_time_zone createdAt
+ boolean disabled
+ varchar_255_ email
+ varchar_32_ firstName
+ uuid id
+ date lastActiveAt
+ varchar_32_ lastName
+ boolean mfaEnabled
+ text mfaRecoveryCodes
+ text mfaSecret
+ varchar_255_ password
+ json personalizationAnswers
+ varchar_128_ roleSlug FK
+ json settings
+ timestamp_3__with_time_zone updatedAt
+}
"public.project" {
timestamp_3__with_time_zone createdAt
uuid creatorId FK
diff --git a/docs/generated/postgres-schema/public.agent_history.md b/docs/generated/postgres-schema/public.agent_history.md
index 6419faf1b68..f04cb3a9138 100644
--- a/docs/generated/postgres-schema/public.agent_history.md
+++ b/docs/generated/postgres-schema/public.agent_history.md
@@ -90,11 +90,13 @@ erDiagram
timestamp_3__with_time_zone updatedAt
}
"public.agent_execution_threads" {
+ varchar_16_ accessScope
varchar_36_ agentId FK
varchar_255_ agentName
timestamp_3__with_time_zone createdAt
varchar_8_ emoji
varchar_128_ id
+ uuid ownerId FK
varchar_36_ parentAgentId
varchar_128_ parentThreadId
varchar_255_ projectId FK
diff --git a/docs/generated/postgres-schema/public.agents.md b/docs/generated/postgres-schema/public.agents.md
index 85b6e4931c4..36eab48dc94 100644
--- a/docs/generated/postgres-schema/public.agents.md
+++ b/docs/generated/postgres-schema/public.agents.md
@@ -183,11 +183,13 @@ erDiagram
timestamp_3__with_time_zone updatedAt
}
"public.agent_execution_threads" {
+ varchar_16_ accessScope
varchar_36_ agentId FK
varchar_255_ agentName
timestamp_3__with_time_zone createdAt
varchar_8_ emoji
varchar_128_ id
+ uuid ownerId FK
varchar_36_ parentAgentId
varchar_128_ parentThreadId
varchar_255_ projectId FK
diff --git a/docs/generated/postgres-schema/public.project.md b/docs/generated/postgres-schema/public.project.md
index 2dc8cc26f81..9b9c45856f5 100644
--- a/docs/generated/postgres-schema/public.project.md
+++ b/docs/generated/postgres-schema/public.project.md
@@ -115,11 +115,13 @@ erDiagram
timestamp_3__with_time_zone updatedAt
}
"public.agent_execution_threads" {
+ varchar_16_ accessScope
varchar_36_ agentId FK
varchar_255_ agentName
timestamp_3__with_time_zone createdAt
varchar_8_ emoji
varchar_128_ id
+ uuid ownerId FK
varchar_36_ parentAgentId
varchar_128_ parentThreadId
varchar_255_ projectId FK
diff --git a/docs/generated/postgres-schema/public.user.md b/docs/generated/postgres-schema/public.user.md
index bb0380a5e45..18893de1160 100644
--- a/docs/generated/postgres-schema/public.user.md
+++ b/docs/generated/postgres-schema/public.user.md
@@ -8,7 +8,7 @@
| disabled | boolean | false | false | | | |
| email | varchar(255) | | true | | | |
| firstName | varchar(32) | | true | | | |
-| id | uuid | gen_random_uuid() | false | [public.activity_event](public.activity_event.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_eval_rating](public.agent_eval_rating.md) [public.agent_eval_run](public.agent_eval_run.md) [public.agent_history](public.agent_history.md) [public.ai_preference](public.ai_preference.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) [public.workflow_review_request](public.workflow_review_request.md) [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) | | |
+| id | uuid | gen_random_uuid() | false | [public.activity_event](public.activity_event.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_eval_rating](public.agent_eval_rating.md) [public.agent_eval_run](public.agent_eval_run.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_history](public.agent_history.md) [public.ai_preference](public.ai_preference.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) [public.workflow_review_request](public.workflow_review_request.md) [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) | | |
| lastActiveAt | date | | true | | | |
| lastName | varchar(32) | | true | | | |
| mfaEnabled | boolean | false | false | | | |
@@ -51,6 +51,7 @@ erDiagram
"public.agent_eval_dataset" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.agent_eval_rating" }o--o| "public.user" : "FOREIGN KEY (#quot;ratedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.agent_eval_run" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
+"public.agent_execution_threads" }o--o| "public.user" : "FOREIGN KEY (#quot;ownerId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.agent_history" }o--o| "public.user" : "FOREIGN KEY (#quot;publishedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.ai_preference" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.ai_preference" }o--o| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
@@ -150,6 +151,27 @@ erDiagram
varchar status
timestamp_3__with_time_zone updatedAt
}
+"public.agent_execution_threads" {
+ varchar_16_ accessScope
+ varchar_36_ agentId FK
+ varchar_255_ agentName
+ timestamp_3__with_time_zone createdAt
+ varchar_8_ emoji
+ varchar_128_ id
+ uuid ownerId FK
+ varchar_36_ parentAgentId
+ varchar_128_ parentThreadId
+ varchar_255_ projectId FK
+ integer sessionNumber
+ varchar_32_ taskId
+ varchar_36_ taskVersionId FK
+ varchar_255_ title
+ integer totalCompletionTokens
+ double_precision totalCost
+ integer totalDuration
+ integer totalPromptTokens
+ timestamp_3__with_time_zone updatedAt
+}
"public.agent_history" {
varchar_36_ agentId FK
varchar_255_ author
diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md
index d96be800abb..0c95ff66aec 100644
--- a/docs/generated/sqlite-schema/README.md
+++ b/docs/generated/sqlite-schema/README.md
@@ -20,7 +20,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [agent_eval_result](agent_eval_result.md) | 15 | | table |
| [agent_eval_run](agent_eval_run.md) | 14 | | table |
| [agent_execution](agent_execution.md) | 22 | | table |
-| [agent_execution_threads](agent_execution_threads.md) | 17 | | table |
+| [agent_execution_threads](agent_execution_threads.md) | 19 | | table |
| [agent_files](agent_files.md) | 10 | | table |
| [agent_history](agent_history.md) | 9 | | table |
| [agent_task_definition](agent_task_definition.md) | 8 | | table |
@@ -172,9 +172,10 @@ erDiagram
"agent_eval_run" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agent_eval_run" }o--|| "agent_eval_dataset" : "FOREIGN KEY (datasetId) REFERENCES agent_eval_dataset (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_execution" }o--|| "agent_execution_threads" : "FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
-"agent_execution_threads" }o--o| "agent_history" : "FOREIGN KEY (taskVersionId) REFERENCES agent_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
-"agent_execution_threads" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
+"agent_execution_threads" }o--o| "user" : "FOREIGN KEY (ownerId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agent_execution_threads" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
+"agent_execution_threads" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
+"agent_execution_threads" }o--o| "agent_history" : "FOREIGN KEY (taskVersionId) REFERENCES agent_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agent_files" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_history" }o--o| "user" : "FOREIGN KEY (publishedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agent_history" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -524,11 +525,13 @@ erDiagram
TEXT userMessage
}
"agent_execution_threads" {
+ varchar_16_ accessScope
varchar_36_ agentId FK
varchar_255_ agentName
datetime_3_ createdAt
varchar_8_ emoji
varchar_128_ id PK
+ varchar ownerId FK
varchar_36_ parentAgentId
varchar_128_ parentThreadId
varchar_255_ projectId FK
diff --git a/docs/generated/sqlite-schema/agent_checkpoints.md b/docs/generated/sqlite-schema/agent_checkpoints.md
index ac75abf87eb..3af00512ccc 100644
--- a/docs/generated/sqlite-schema/agent_checkpoints.md
+++ b/docs/generated/sqlite-schema/agent_checkpoints.md
@@ -37,6 +37,7 @@ CREATE TABLE "agent_checkpoints" ("runId" varchar(255) PRIMARY KEY NOT NULL, "ag
| ---- | ---------- |
| IDX_5e31c210f896d539964bf99fe3 | CREATE INDEX "IDX_5e31c210f896d539964bf99fe3" ON "agent_checkpoints" ("agentId") |
| IDX_agent_checkpoints_thread | CREATE INDEX "IDX_agent_checkpoints_thread" ON "agent_checkpoints" ("agentId", "threadId", "expired", "updatedAt") |
+| IDX_agent_checkpoints_threadId | CREATE INDEX "IDX_agent_checkpoints_threadId" ON "agent_checkpoints" ("threadId") |
| sqlite_autoindex_agent_checkpoints_1 | PRIMARY KEY (runId) |
## Relations
diff --git a/docs/generated/sqlite-schema/agent_execution.md b/docs/generated/sqlite-schema/agent_execution.md
index 1ce49459a6b..ac706d29144 100644
--- a/docs/generated/sqlite-schema/agent_execution.md
+++ b/docs/generated/sqlite-schema/agent_execution.md
@@ -89,11 +89,13 @@ erDiagram
TEXT userMessage
}
"agent_execution_threads" {
+ varchar_16_ accessScope
varchar_36_ agentId FK
varchar_255_ agentName
datetime_3_ createdAt
varchar_8_ emoji
varchar_128_ id PK
+ varchar ownerId FK
varchar_36_ parentAgentId
varchar_128_ parentThreadId
varchar_255_ projectId FK
diff --git a/docs/generated/sqlite-schema/agent_execution_threads.md b/docs/generated/sqlite-schema/agent_execution_threads.md
index 6f45c1b8120..73929ffa67e 100644
--- a/docs/generated/sqlite-schema/agent_execution_threads.md
+++ b/docs/generated/sqlite-schema/agent_execution_threads.md
@@ -6,7 +6,7 @@
Table Definition
```sql
-CREATE TABLE "agent_execution_threads" ("id" varchar(128) PRIMARY KEY NOT NULL, "agentId" varchar(36) NOT NULL, "agentName" varchar(255) NOT NULL, "projectId" varchar(255) NOT NULL, "sessionNumber" integer NOT NULL DEFAULT (0), "totalPromptTokens" integer NOT NULL DEFAULT (0), "totalCompletionTokens" integer NOT NULL DEFAULT (0), "totalCost" real NOT NULL DEFAULT (0), "totalDuration" integer NOT NULL DEFAULT (0), "title" varchar(255), "emoji" varchar(8), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "taskId" varchar(32), "taskVersionId" varchar(36), "parentThreadId" varchar(128), "parentAgentId" varchar(36), CONSTRAINT "FK_0e2f8bf92a7a9c88b89670f701c" FOREIGN KEY ("projectId") REFERENCES "project" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_0468a9dc35597314e641d4722aa" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f00b52d74fe11838e1fe086deea" FOREIGN KEY ("taskVersionId") REFERENCES "agent_history" ("versionId") ON DELETE SET NULL)
+CREATE TABLE "agent_execution_threads" ("id" varchar(128) PRIMARY KEY NOT NULL, "agentId" varchar(36) NOT NULL, "agentName" varchar(255) NOT NULL, "projectId" varchar(255) NOT NULL, "sessionNumber" integer NOT NULL DEFAULT (0), "totalPromptTokens" integer NOT NULL DEFAULT (0), "totalCompletionTokens" integer NOT NULL DEFAULT (0), "totalCost" real NOT NULL DEFAULT (0), "totalDuration" integer NOT NULL DEFAULT (0), "title" varchar(255), "emoji" varchar(8), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "taskId" varchar(32), "taskVersionId" varchar(36), "parentThreadId" varchar(128), "parentAgentId" varchar(36), "ownerId" varchar, "accessScope" varchar(16) NOT NULL DEFAULT ('user'), CONSTRAINT "CHK_agent_execution_threads_accessScope" CHECK ("accessScope" IN ('user', 'project')), CONSTRAINT "FK_f00b52d74fe11838e1fe086deea" FOREIGN KEY ("taskVersionId") REFERENCES "agent_history" ("versionId") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_0468a9dc35597314e641d4722aa" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_0e2f8bf92a7a9c88b89670f701c" FOREIGN KEY ("projectId") REFERENCES "project" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_agent_execution_threads_owner" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON DELETE SET NULL)
```
@@ -15,11 +15,13 @@ CREATE TABLE "agent_execution_threads" ("id" varchar(128) PRIMARY KEY NOT NULL,
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
+| accessScope | varchar(16) | 'user' | false | | | |
| agentId | varchar(36) | | false | | [agents](agents.md) | |
| agentName | varchar(255) | | false | | | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| emoji | varchar(8) | | true | | | |
| id | varchar(128) | | false | [agent_execution](agent_execution.md) | | |
+| ownerId | varchar | | true | | [user](user.md) | |
| parentAgentId | varchar(36) | | true | | | |
| parentThreadId | varchar(128) | | true | | | |
| projectId | varchar(255) | | false | | [project](project.md) | |
@@ -37,9 +39,11 @@ CREATE TABLE "agent_execution_threads" ("id" varchar(128) PRIMARY KEY NOT NULL,
| Name | Type | Definition |
| ---- | ---- | ---------- |
-| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (taskVersionId) REFERENCES agent_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
-| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
-| - (Foreign key ID: 2) | FOREIGN KEY | FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
+| - | CHECK | CHECK ("accessScope" IN ('user', 'project')) |
+| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (ownerId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
+| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
+| - (Foreign key ID: 2) | FOREIGN KEY | FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
+| - (Foreign key ID: 3) | FOREIGN KEY | FOREIGN KEY (taskVersionId) REFERENCES agent_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
| id | PRIMARY KEY | PRIMARY KEY (id) |
| sqlite_autoindex_agent_execution_threads_1 | PRIMARY KEY | PRIMARY KEY (id) |
@@ -49,6 +53,7 @@ CREATE TABLE "agent_execution_threads" ("id" varchar(128) PRIMARY KEY NOT NULL,
| ---- | ---------- |
| IDX_0468a9dc35597314e641d4722a | CREATE INDEX "IDX_0468a9dc35597314e641d4722a" ON "agent_execution_threads" ("agentId") |
| IDX_0e2f8bf92a7a9c88b89670f701 | CREATE INDEX "IDX_0e2f8bf92a7a9c88b89670f701" ON "agent_execution_threads" ("projectId") |
+| IDX_agent_execution_threads_ownerId | CREATE INDEX "IDX_agent_execution_threads_ownerId" ON "agent_execution_threads" ("ownerId") |
| IDX_agent_execution_threads_taskVersionId | CREATE INDEX "IDX_agent_execution_threads_taskVersionId" ON "agent_execution_threads" ("taskVersionId") |
| sqlite_autoindex_agent_execution_threads_1 | PRIMARY KEY (id) |
@@ -59,15 +64,18 @@ erDiagram
"agent_execution_threads" }o--|| "agents" : "FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_execution" }o--|| "agent_execution_threads" : "FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
+"agent_execution_threads" }o--o| "user" : "FOREIGN KEY (ownerId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agent_execution_threads" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"agent_execution_threads" }o--o| "agent_history" : "FOREIGN KEY (taskVersionId) REFERENCES agent_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agent_execution_threads" {
+ varchar_16_ accessScope
varchar_36_ agentId FK
varchar_255_ agentName
datetime_3_ createdAt
varchar_8_ emoji
varchar_128_ id PK
+ varchar ownerId FK
varchar_36_ parentAgentId
varchar_128_ parentThreadId
varchar_255_ projectId FK
@@ -121,6 +129,23 @@ erDiagram
datetime_3_ updatedAt
TEXT userMessage
}
+"user" {
+ datetime_3_ createdAt
+ boolean disabled
+ varchar_255_ email
+ varchar_32_ firstName
+ varchar id PK
+ date lastActiveAt
+ varchar_32_ lastName
+ boolean mfaEnabled
+ TEXT mfaRecoveryCodes
+ TEXT mfaSecret
+ varchar password
+ TEXT personalizationAnswers
+ varchar_128_ roleSlug FK
+ TEXT settings
+ datetime_3_ updatedAt
+}
"project" {
datetime_3_ createdAt
varchar creatorId FK
diff --git a/docs/generated/sqlite-schema/agent_history.md b/docs/generated/sqlite-schema/agent_history.md
index 4f6b05a5b8d..af7c4ea1e48 100644
--- a/docs/generated/sqlite-schema/agent_history.md
+++ b/docs/generated/sqlite-schema/agent_history.md
@@ -97,11 +97,13 @@ erDiagram
datetime_3_ updatedAt
}
"agent_execution_threads" {
+ varchar_16_ accessScope
varchar_36_ agentId FK
varchar_255_ agentName
datetime_3_ createdAt
varchar_8_ emoji
varchar_128_ id PK
+ varchar ownerId FK
varchar_36_ parentAgentId
varchar_128_ parentThreadId
varchar_255_ projectId FK
diff --git a/docs/generated/sqlite-schema/agents.md b/docs/generated/sqlite-schema/agents.md
index a1ef0832e85..ad02fc08076 100644
--- a/docs/generated/sqlite-schema/agents.md
+++ b/docs/generated/sqlite-schema/agents.md
@@ -185,11 +185,13 @@ erDiagram
datetime_3_ updatedAt
}
"agent_execution_threads" {
+ varchar_16_ accessScope
varchar_36_ agentId FK
varchar_255_ agentName
datetime_3_ createdAt
varchar_8_ emoji
varchar_128_ id PK
+ varchar ownerId FK
varchar_36_ parentAgentId
varchar_128_ parentThreadId
varchar_255_ projectId FK
diff --git a/docs/generated/sqlite-schema/project.md b/docs/generated/sqlite-schema/project.md
index c1f2ee5383a..7afeee307fd 100644
--- a/docs/generated/sqlite-schema/project.md
+++ b/docs/generated/sqlite-schema/project.md
@@ -121,11 +121,13 @@ erDiagram
datetime_3_ updatedAt
}
"agent_execution_threads" {
+ varchar_16_ accessScope
varchar_36_ agentId FK
varchar_255_ agentName
datetime_3_ createdAt
varchar_8_ emoji
varchar_128_ id PK
+ varchar ownerId FK
varchar_36_ parentAgentId
varchar_128_ parentThreadId
varchar_255_ projectId FK
diff --git a/docs/generated/sqlite-schema/user.md b/docs/generated/sqlite-schema/user.md
index b90300e9bfe..494565cd4c0 100644
--- a/docs/generated/sqlite-schema/user.md
+++ b/docs/generated/sqlite-schema/user.md
@@ -19,7 +19,7 @@ CREATE TABLE "user" ("id" varchar PRIMARY KEY, "email" varchar(255), "firstName"
| disabled | boolean | FALSE | false | | | |
| email | varchar(255) | | true | | | |
| firstName | varchar(32) | | true | | | |
-| id | varchar | | true | [activity_event](activity_event.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_history](agent_history.md) [ai_preference](ai_preference.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) [workflow_review_activity](workflow_review_activity.md) [workflow_review_activity_comment](workflow_review_activity_comment.md) [workflow_review_request](workflow_review_request.md) [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) | | |
+| id | varchar | | true | [activity_event](activity_event.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_execution_threads](agent_execution_threads.md) [agent_history](agent_history.md) [ai_preference](ai_preference.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) [workflow_review_activity](workflow_review_activity.md) [workflow_review_activity_comment](workflow_review_activity_comment.md) [workflow_review_request](workflow_review_request.md) [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) | | |
| lastActiveAt | date | | true | | | |
| lastName | varchar(32) | | true | | | |
| mfaEnabled | boolean | FALSE | false | | | |
@@ -57,6 +57,7 @@ erDiagram
"agent_eval_dataset" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agent_eval_rating" }o--o| "user" : "FOREIGN KEY (ratedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agent_eval_run" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
+"agent_execution_threads" }o--o| "user" : "FOREIGN KEY (ownerId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"agent_history" }o--o| "user" : "FOREIGN KEY (publishedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"ai_preference" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"ai_preference" }o--o| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -156,6 +157,27 @@ erDiagram
varchar status
datetime_3_ updatedAt
}
+"agent_execution_threads" {
+ varchar_16_ accessScope
+ varchar_36_ agentId FK
+ varchar_255_ agentName
+ datetime_3_ createdAt
+ varchar_8_ emoji
+ varchar_128_ id PK
+ varchar ownerId FK
+ varchar_36_ parentAgentId
+ varchar_128_ parentThreadId
+ varchar_255_ projectId FK
+ INTEGER sessionNumber
+ varchar_32_ taskId
+ varchar_36_ taskVersionId FK
+ varchar_255_ title
+ INTEGER totalCompletionTokens
+ REAL totalCost
+ INTEGER totalDuration
+ INTEGER totalPromptTokens
+ datetime_3_ updatedAt
+}
"agent_history" {
varchar_36_ agentId FK
varchar_255_ author
diff --git a/packages/@n8n/db/src/migrations/common/1790014447117-AddAgentThreadOwnership.ts b/packages/@n8n/db/src/migrations/common/1790014447117-AddAgentThreadOwnership.ts
new file mode 100644
index 00000000000..2ca3dc2e4e4
--- /dev/null
+++ b/packages/@n8n/db/src/migrations/common/1790014447117-AddAgentThreadOwnership.ts
@@ -0,0 +1,117 @@
+import type { MigrationContext, ReversibleMigration } from '../migration-types';
+
+const previewSources = ['n8n_chat', 'mcp', 'instance-ai'];
+const projectSources = ['slack', 'telegram', 'discord', 'linear', 'task', 'workflow', 'schedule'];
+
+export class AddAgentThreadOwnership1790014447117 implements ReversibleMigration {
+ async up(context: MigrationContext) {
+ const {
+ schemaBuilder: { addColumns, column, addForeignKey, createIndex },
+ } = context;
+ await addColumns(
+ 'agent_execution_threads',
+ [
+ column('ownerId').uuid.comment('User who started this private session'),
+ column('accessScope')
+ .varchar(16)
+ .notNull.default("'user'")
+ .withEnumCheck(['user', 'project'])
+ .comment('user: private session; project: shared integration, workflow, or task session'),
+ ],
+ { recreatesOnSqlite: true },
+ );
+ await addForeignKey(
+ 'agent_execution_threads',
+ 'ownerId',
+ ['user', 'id'],
+ 'FK_agent_execution_threads_owner',
+ 'SET NULL',
+ );
+ await createIndex('agent_checkpoints', ['threadId']);
+ await this.backfill(context);
+ await this.backfillInheritedAccess(context);
+ await createIndex('agent_execution_threads', ['ownerId']);
+ }
+
+ async down({
+ schemaBuilder: { dropForeignKey, dropIndex, dropColumns, dropEnumCheck },
+ }: MigrationContext) {
+ await dropForeignKey(
+ 'agent_execution_threads',
+ 'ownerId',
+ ['user', 'id'],
+ 'FK_agent_execution_threads_owner',
+ );
+ await dropIndex('agent_execution_threads', ['ownerId']);
+ await dropIndex('agent_checkpoints', ['threadId']);
+ await dropEnumCheck('agent_execution_threads', 'accessScope', { recreatesOnSqlite: true });
+ await dropColumns('agent_execution_threads', ['ownerId', 'accessScope'], {
+ recreatesOnSqlite: true,
+ });
+ }
+
+ private async backfill({ escape, runQuery }: MigrationContext) {
+ const c = escape.columnName;
+ const threads = escape.tableName('agent_execution_threads');
+ const executions = escape.tableName('agent_execution');
+ const memory = escape.tableName('agents_threads');
+ const users = escape.tableName('user');
+ const threadId = `${threads}.${c('id')}`;
+ const hasPreviewMemory = `EXISTS (
+ SELECT 1 FROM ${memory} m
+ WHERE m.${c('id')} = ${threadId} AND m.${c('resourceId')} LIKE 'draft-chat:%'
+ )`;
+ const hasPreviewExecution = `EXISTS (
+ SELECT 1 FROM ${executions} e
+ WHERE e.${c('threadId')} = ${threadId}
+ AND LOWER(TRIM(e.${c('source')})) IN (:...previewSources)
+ )`;
+ const hasProjectExecution = `EXISTS (
+ SELECT 1 FROM ${executions} e
+ WHERE e.${c('threadId')} = ${threadId}
+ AND LOWER(TRIM(e.${c('source')})) IN (:...projectSources)
+ )`;
+ const hasProjectMemory = `EXISTS (
+ SELECT 1 FROM ${memory} m
+ WHERE m.${c('id')} = ${threadId}
+ AND (m.${c('resourceId')} LIKE 'integration:%' OR m.${c('resourceId')} LIKE 'task:%')
+ )`;
+
+ await runQuery(
+ `UPDATE ${threads} SET ${c('accessScope')} = 'project'
+ WHERE (${threads}.${c('taskId')} IS NOT NULL OR ${hasProjectMemory} OR ${hasProjectExecution})
+ AND NOT (${hasPreviewMemory} OR ${hasPreviewExecution})`,
+ { previewSources, projectSources },
+ );
+
+ await runQuery(`UPDATE ${threads} SET ${c('ownerId')} = u.${c('id')}
+ FROM ${memory} m JOIN ${users} u
+ ON m.${c('resourceId')} = 'draft-chat:' || CAST(u.${c('id')} AS TEXT)
+ WHERE m.${c('id')} = ${threadId}
+ AND ${threads}.${c('accessScope')} = 'user'`);
+ }
+
+ private async backfillInheritedAccess({ escape, runQuery }: MigrationContext) {
+ const c = escape.columnName;
+ const threads = escape.tableName('agent_execution_threads');
+ const parentMatches = `parent.${c('id')} = ${threads}.${c('parentThreadId')}
+ AND parent.${c('agentId')} = ${threads}.${c('parentAgentId')}
+ AND parent.${c('projectId')} = ${threads}.${c('projectId')}`;
+ const parentHasAccess = `(parent.${c('accessScope')} = 'project'
+ OR parent.${c('ownerId')} IS NOT NULL)`;
+
+ await runQuery(`UPDATE ${threads} SET
+ ${c('ownerId')} = (
+ SELECT parent.${c('ownerId')} FROM ${threads} parent
+ WHERE ${parentMatches} AND ${parentHasAccess}
+ ),
+ ${c('accessScope')} = (
+ SELECT parent.${c('accessScope')} FROM ${threads} parent
+ WHERE ${parentMatches} AND ${parentHasAccess}
+ )
+ WHERE EXISTS (
+ SELECT 1 FROM ${threads} parent
+ WHERE ${parentMatches} AND ${parentHasAccess}
+ )`);
+ }
+}
diff --git a/packages/@n8n/db/src/migrations/sqlite/1790014447117-AddAgentThreadOwnership.ts b/packages/@n8n/db/src/migrations/sqlite/1790014447117-AddAgentThreadOwnership.ts
new file mode 100644
index 00000000000..a32e7766f8d
--- /dev/null
+++ b/packages/@n8n/db/src/migrations/sqlite/1790014447117-AddAgentThreadOwnership.ts
@@ -0,0 +1,5 @@
+import { AddAgentThreadOwnership1790014447117 as BaseMigration } from '../common/1790014447117-AddAgentThreadOwnership';
+
+export class AddAgentThreadOwnership1790014447117 extends BaseMigration {
+ withFKsDisabled = true as const;
+}
diff --git a/packages/cli/src/modules/agents/__tests__/agent-chat-attachment.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-chat-attachment.service.test.ts
index 132ead852c2..fe7b6903874 100644
--- a/packages/cli/src/modules/agents/__tests__/agent-chat-attachment.service.test.ts
+++ b/packages/cli/src/modules/agents/__tests__/agent-chat-attachment.service.test.ts
@@ -4,18 +4,56 @@ import { mock } from 'vitest-mock-extended';
import { AgentChatAttachmentService } from '../agent-chat-attachment.service';
import type { AgentChatAttachment } from '../entities/agent-chat-attachment.entity';
+import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
+import type { AgentExecutionThreadRepository } from '../repositories/agent-execution-thread.repository';
import type { AgentChatAttachmentRepository } from '../repositories/agent-chat-attachment.repository';
describe('AgentChatAttachmentService', () => {
let binaryDataService = mock();
let repository = mock();
let service: AgentChatAttachmentService;
+ let threadRepository = mock();
beforeEach(() => {
vi.clearAllMocks();
binaryDataService = mock();
repository = mock();
- service = new AgentChatAttachmentService(mock(), binaryDataService, repository);
+ threadRepository = mock();
+ service = new AgentChatAttachmentService(
+ mock(),
+ binaryDataService,
+ repository,
+ threadRepository,
+ );
+ });
+
+ it('checks attachment access through the session and falls back to its persisted resource', async () => {
+ const scope = { agentId: 'agent-1', projectId: 'project-1', userId: 'user-1' };
+ const attachment = mock({
+ threadId: 'thread-1',
+ resourceId: 'draft-chat:user-1',
+ });
+ repository.findByIdForAgent.mockResolvedValue(attachment);
+ const thread = mock({
+ agentId: scope.agentId,
+ projectId: scope.projectId,
+ accessScope: 'user',
+ ownerId: scope.userId,
+ });
+ threadRepository.findOneBy.mockResolvedValue(thread);
+ expect(await service.getForAgent('att-1', scope)).toEqual(attachment);
+ expect(await service.getForAgent('att-1', { ...scope, userId: 'user-2' })).toBeNull();
+ threadRepository.findOneBy.mockResolvedValue({ ...thread, ownerId: null });
+ expect(await service.getForAgent('att-1', scope)).toBeNull();
+ threadRepository.findOneBy.mockResolvedValue(null);
+ expect(await service.getForAgent('att-1', scope)).toEqual(attachment);
+ expect(await service.getForAgent('att-1', { ...scope, userId: 'user-2' })).toBeNull();
+ threadRepository.findOneBy.mockResolvedValue({
+ ...thread,
+ accessScope: 'project',
+ ownerId: null,
+ });
+ expect(await service.getForAgent('att-1', { ...scope, userId: 'user-2' })).toEqual(attachment);
});
describe('storeInbound', () => {
diff --git a/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts
index a8c02721297..1da58e40423 100644
--- a/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts
+++ b/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts
@@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events';
+import type { SerializableAgentState } from '@n8n/agents';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
@@ -37,6 +38,7 @@ function makeController() {
const agentValidationService = mock();
const backgroundJobService = mock();
agentExecutionService.findThreadById.mockResolvedValue(null);
+ agentExecutionService.canUsePreviewThread.mockResolvedValue(true);
agentValidationService.validateAgentIsRunnable.mockResolvedValue({ missing: [] });
const agentTestRunService = new AgentTestRunService(
agentExecutionService,
@@ -59,6 +61,7 @@ function makeController() {
return {
controller,
+ agentsBuilderService,
agentExecutionService,
backgroundJobService,
agentExecutionOrchestratorService,
@@ -128,8 +131,13 @@ describe('AgentChatController background tasks', () => {
id: 'thread-1',
projectId: 'project-1',
agentId: 'agent-1',
+ accessScope: 'user',
+ ownerId: 'user-1',
});
- const request = { params: { projectId: 'project-1', agentId: 'agent-1', threadId: 'thread-1' } };
+ const request = {
+ params: { projectId: 'project-1', agentId: 'agent-1', threadId: 'thread-1' },
+ user: { id: 'user-1' },
+ };
it('scrubs task titles in the response', async () => {
const { controller, agentsService, agentExecutionService, backgroundJobService } =
@@ -243,17 +251,19 @@ describe('AgentChatController background tasks', () => {
expect(backgroundJobService.markMailConsumed).not.toHaveBeenCalled();
});
- it.each([{ projectId: 'other-project' }, { agentId: 'other-agent' }])(
- 'rejects an unrelated thread: %s',
- async (overrides) => {
- const { controller, agentsService, agentExecutionService, backgroundJobService } =
- makeController();
- agentsService.findById.mockResolvedValue({ id: 'agent-1' } as never);
- agentExecutionService.findThreadById.mockResolvedValue({ ...thread, ...overrides });
- await expect(controller.getBackgroundJobs(request as never)).rejects.toThrow(NotFoundError);
- expect(backgroundJobService.listCurrentGroupForThread).not.toHaveBeenCalled();
- },
- );
+ it.each([
+ { projectId: 'other-project' },
+ { agentId: 'other-agent' },
+ { ownerId: 'other-user' },
+ { ownerId: null },
+ ])('rejects an unrelated thread: %s', async (overrides) => {
+ const { controller, agentsService, agentExecutionService, backgroundJobService } =
+ makeController();
+ agentsService.findById.mockResolvedValue({ id: 'agent-1' } as never);
+ agentExecutionService.findThreadById.mockResolvedValue({ ...thread, ...overrides });
+ await expect(controller.getBackgroundJobs(request as never)).rejects.toThrow(NotFoundError);
+ expect(backgroundJobService.listCurrentGroupForThread).not.toHaveBeenCalled();
+ });
it('returns no tasks for a new session and rejects an unknown agent', async () => {
const { controller, agentsService, agentExecutionService, backgroundJobService } =
@@ -268,6 +278,77 @@ describe('AgentChatController background tasks', () => {
});
describe('AgentChatController chat message history', () => {
+ it('uses an owned checkpoint only when execution history is absent', async () => {
+ const { controller, agentsService, agentsBuilderService, agentExecutionService } =
+ makeController();
+ agentsService.findById.mockResolvedValue({ id: 'agent-1' } as never);
+ agentsService.getConversationHistory.mockResolvedValue(null);
+ const checkpoint = mock({
+ persistence: { threadId: 'thread-1', resourceId: 'draft-chat:user-1' },
+ messageList: { messages: [] },
+ pendingToolCalls: {},
+ });
+ agentsBuilderService.findOpenCheckpointForThread.mockResolvedValue(checkpoint);
+ const request = {
+ params: { projectId: 'project-1', agentId: 'agent-1', threadId: 'thread-1' },
+ user: { id: 'user-1' },
+ };
+ expect(await controller.getChatMessages(request as never)).toEqual({
+ messages: [],
+ openSuspensions: [],
+ });
+ await expect(
+ controller.getChatMessages({ ...request, user: { id: 'user-2' } } as never),
+ ).rejects.toThrow(NotFoundError);
+ agentExecutionService.findThreadById.mockResolvedValue(
+ mock({
+ projectId: 'project-1',
+ agentId: 'agent-1',
+ accessScope: 'user',
+ ownerId: null,
+ }),
+ );
+ agentsBuilderService.findOpenCheckpointForThread.mockClear();
+ await expect(controller.getChatMessages(request as never)).rejects.toThrow(NotFoundError);
+ expect(agentsBuilderService.findOpenCheckpointForThread).not.toHaveBeenCalled();
+ });
+
+ it('accepts only shared checkpoints for a project-scoped thread', async () => {
+ const { controller, agentsService, agentsBuilderService, agentExecutionService } =
+ makeController();
+ agentsService.findById.mockResolvedValue({ id: 'agent-1' } as never);
+ agentsService.getConversationHistory.mockResolvedValue(null);
+ agentExecutionService.findThreadById.mockResolvedValue(
+ mock({
+ projectId: 'project-1',
+ agentId: 'agent-1',
+ accessScope: 'project',
+ ownerId: null,
+ }),
+ );
+ const request = {
+ params: { projectId: 'project-1', agentId: 'agent-1', threadId: 'thread-1' },
+ user: { id: 'user-1' },
+ };
+ const checkpoint = mock({
+ persistence: { threadId: 'thread-1', resourceId: 'integration:slack:user-1' },
+ messageList: { messages: [] },
+ pendingToolCalls: {},
+ });
+ agentsBuilderService.findOpenCheckpointForThread.mockResolvedValue(checkpoint);
+
+ await expect(controller.getChatMessages(request as never)).resolves.toEqual({
+ messages: [],
+ openSuspensions: [],
+ });
+
+ agentsBuilderService.findOpenCheckpointForThread.mockResolvedValue({
+ ...checkpoint,
+ persistence: { threadId: 'thread-1', resourceId: 'draft-chat:user-1' },
+ });
+ await expect(controller.getChatMessages(request as never)).rejects.toThrow(NotFoundError);
+ });
+
it('returns conversation history envelope from the execution orchestrator', async () => {
const { controller, agentsService } = makeController();
agentsService.findById.mockResolvedValue({ id: 'agent-1' } as never);
@@ -285,6 +366,7 @@ describe('AgentChatController chat message history', () => {
]);
const result = await controller.getChatMessages({
+ user: { id: 'user-1' },
params: { projectId: 'project-1', agentId: 'agent-1', threadId: 'thread-1' },
} as never);
@@ -304,6 +386,7 @@ describe('AgentChatController chat message history', () => {
openSuspensions: [],
});
expect(agentsService.getConversationHistory).toHaveBeenCalledWith({
+ userId: 'user-1',
threadId: 'thread-1',
projectId: 'project-1',
agentId: 'agent-1',
@@ -317,6 +400,7 @@ describe('AgentChatController chat message history', () => {
await expect(
controller.getChatMessages({
+ user: { id: 'user-1' },
params: { projectId: 'project-1', agentId: 'agent-1', threadId: 'thread-1' },
} as never),
).rejects.toThrow(NotFoundError);
@@ -659,6 +743,25 @@ describe('AgentChatController attachment cleanup on failed turns', () => {
expect(agentExecutionOrchestratorService.executeForChat).not.toHaveBeenCalled();
});
+ it('rejects an unrelated session before saving an upload or starting a runtime', async () => {
+ const {
+ controller,
+ agentExecutionService,
+ agentChatAttachmentService,
+ agentExecutionOrchestratorService,
+ } = makeController();
+ agentExecutionService.canUsePreviewThread.mockResolvedValue(false);
+ const { res, events } = makeCleanupSseResponse();
+ await controller.chat(
+ { params: { projectId: 'project-1' }, user: { id: 'user-2' } } as never,
+ res,
+ 'agent-1',
+ { sessionId: 'thread-1', message: 'hi', attachments: [textAttachment('notes.txt')] } as never,
+ );
+ expect(events()).toContainEqual(expect.objectContaining({ type: 'error' }));
+ expect(agentChatAttachmentService.storeInbound).not.toHaveBeenCalled();
+ expect(agentExecutionOrchestratorService.executeForChat).not.toHaveBeenCalled();
+ });
it('rejects an empty attachment with a dedicated error message', async () => {
const { controller, agentChatAttachmentService } = makeController();
const { res, events } = makeCleanupSseResponse();
@@ -697,6 +800,7 @@ describe('AgentChatController attachment download', () => {
const req = {
params: { projectId: 'p1', agentId: 'agent-1', attachmentId: 'att-1' },
+ user: { id: 'user-1' },
} as never;
const res = { setHeader: vi.fn() } as never;
diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts
index d40f805c651..196c2ba09ed 100644
--- a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts
+++ b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts
@@ -196,6 +196,7 @@ function makeService(sandboxEnabled = false) {
const wakeService = mock();
Container.set(AgentWakeService, wakeService);
+ executionService.canUsePreviewThread.mockResolvedValue(true);
executionService.startExecutionRecording.mockResolvedValue('execution-1');
executionService.finalizeExecution.mockResolvedValue('execution-1');
agentRunTracingService.build.mockResolvedValue(undefined);
@@ -300,6 +301,8 @@ describe('AgentExecutionOrchestratorService', () => {
abortSignal,
})
: fixtures.service.resumeForChat({
+ user,
+ usePublishedVersion: false,
agentId,
projectId,
runId: 'run-1',
@@ -637,6 +640,7 @@ describe('AgentExecutionOrchestratorService', () => {
await collect(
service.streamChatResponse({
+ access: { accessScope: 'user', ownerId: userId },
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
mcpServerAttributions: runtime.mcpServerAttributions,
@@ -700,6 +704,7 @@ describe('AgentExecutionOrchestratorService', () => {
const chunks = await collect(
service.streamChatResponse({
+ access: { accessScope: 'user', ownerId: userId },
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
mcpServerAttributions: runtime.mcpServerAttributions,
@@ -744,6 +749,7 @@ describe('AgentExecutionOrchestratorService', () => {
const chunks = await collect(
service.streamChatResponse({
+ access: { accessScope: 'user', ownerId: userId },
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
mcpServerAttributions: runtime.mcpServerAttributions,
@@ -777,6 +783,7 @@ describe('AgentExecutionOrchestratorService', () => {
const chunks = await collect(
service.streamChatResponse({
+ access: { accessScope: 'user', ownerId: userId },
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
mcpServerAttributions: runtime.mcpServerAttributions,
@@ -814,6 +821,7 @@ describe('AgentExecutionOrchestratorService', () => {
await collect(
service.streamChatResponse({
+ access: { accessScope: 'user', ownerId: userId },
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
mcpServerAttributions: runtime.mcpServerAttributions,
@@ -853,6 +861,7 @@ describe('AgentExecutionOrchestratorService', () => {
const suspendedChunks = await collect(
service.streamChatResponse({
+ access: { accessScope: 'user', ownerId: userId },
agentInstance: suspended.agent,
toolRegistry: suspended.toolRegistry,
mcpServerAttributions: suspended.mcpServerAttributions,
@@ -913,6 +922,7 @@ describe('AgentExecutionOrchestratorService', () => {
const chunks = await collect(
service.streamChatResponse({
+ access: { accessScope: 'user', ownerId: userId },
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
mcpServerAttributions: runtime.mcpServerAttributions,
@@ -966,6 +976,7 @@ describe('AgentExecutionOrchestratorService', () => {
await collect(
service.streamChatResponse({
+ access: { accessScope: 'user', ownerId: userId },
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
mcpServerAttributions: runtime.mcpServerAttributions,
@@ -990,6 +1001,7 @@ describe('AgentExecutionOrchestratorService', () => {
await collect(
service.streamChatResponse({
+ access: { accessScope: 'user', ownerId: userId },
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
mcpServerAttributions: runtime.mcpServerAttributions,
@@ -1024,7 +1036,7 @@ describe('AgentExecutionOrchestratorService', () => {
projectId,
message: 'hello',
user,
- memory: { threadId: 'thread-1', resourceId: 'resource-1' },
+ memory: { threadId: 'thread-1', resourceId: 'draft-chat:user-1' },
source: 'instance-ai',
}),
);
@@ -1038,7 +1050,7 @@ describe('AgentExecutionOrchestratorService', () => {
});
expect(integrationMessageContextService.setLatest).toHaveBeenCalledWith(
'thread-1',
- 'resource-1',
+ 'draft-chat:user-1',
expect.objectContaining({
integrationConnectionId: N8N_CHAT_INTEGRATION_TYPE,
platform: N8N_CHAT_INTEGRATION_TYPE,
@@ -1072,6 +1084,38 @@ describe('AgentExecutionOrchestratorService', () => {
);
});
+ it.each(['chat', 'wake'] as const)(
+ 'rejects an inaccessible draft %s before runtime acquisition',
+ async (operation) => {
+ const { service, executionService, runtimeCacheService } = makeService();
+ executionService.canUsePreviewThread.mockResolvedValue(false);
+ const result =
+ operation === 'chat'
+ ? collect(
+ service.executeForChat({
+ agentId,
+ projectId,
+ message: 'hello',
+ user,
+ memory: { threadId: 'thread-1', resourceId: 'draft-chat:user-1' },
+ }),
+ )
+ : service.executeForWake({
+ backgroundJobSignal,
+ agentId,
+ projectId,
+ message: 'Wake',
+ memory: { threadId: 'thread-1', resourceId: 'draft-chat:user-1' },
+ identity: { type: 'draft', user, principalHash: userPrincipalHash },
+ abortSignal: new AbortController().signal,
+ });
+
+ await expect(result).rejects.toThrow('Session not found');
+ expect(runtimeCacheService.getRuntime).not.toHaveBeenCalled();
+ expect(executionService.startExecutionRecording).not.toHaveBeenCalled();
+ },
+ );
+
it('adds full tool configuration to preview approval payloads only', async () => {
const { service, runtimeCacheService } = makeService();
const approvalChunk: StreamChunk = {
@@ -1108,7 +1152,7 @@ describe('AgentExecutionOrchestratorService', () => {
projectId,
message: 'check the ledger',
user,
- memory: { threadId: 'thread-1', resourceId: 'resource-1' },
+ memory: { threadId: 'thread-1', resourceId: 'draft-chat:user-1' },
}),
);
const publishedChunks = await collect(
@@ -1239,6 +1283,10 @@ describe('AgentExecutionOrchestratorService', () => {
});
expect(onExecutionRecorded).not.toHaveBeenCalled();
}
+ expect(executionService.startExecutionRecording).toHaveBeenCalledWith(
+ expect.objectContaining({ access: { accessScope: 'user', ownerId: user.id } }),
+ expect.any(Date),
+ );
},
);
@@ -1283,6 +1331,8 @@ describe('AgentExecutionOrchestratorService', () => {
});
await collect(
service.resumeForChat({
+ user,
+ usePublishedVersion: false,
agentId,
projectId,
runId: 'run-1',
@@ -1493,7 +1543,7 @@ describe('AgentExecutionOrchestratorService', () => {
});
it('does not run the quota hook for manually started scheduled tasks', async () => {
- const { service, runtimeCacheService, externalHooks } = makeService();
+ const { service, runtimeCacheService, externalHooks, executionService } = makeService();
const runtime = makeRuntime([{ type: 'finish', finishReason: 'stop' }]);
runtimeCacheService.getRuntime.mockResolvedValue(runtime);
@@ -1509,6 +1559,10 @@ describe('AgentExecutionOrchestratorService', () => {
);
expect(externalHooks.run).not.toHaveBeenCalled();
+ expect(executionService.startExecutionRecording).toHaveBeenCalledWith(
+ expect.objectContaining({ access: { accessScope: 'project', ownerId: null } }),
+ expect.any(Date),
+ );
expect(runtimeCacheService.getRuntime).toHaveBeenCalledWith(
expect.objectContaining({
sandboxPrincipalHash: userPrincipalHash,
@@ -1854,6 +1908,7 @@ describe('AgentExecutionOrchestratorService', () => {
const chunks = await collect(
service.streamChatResponse({
+ access: { accessScope: 'user', ownerId: userId },
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
mcpServerAttributions: runtime.mcpServerAttributions,
@@ -1893,6 +1948,7 @@ describe('AgentExecutionOrchestratorService', () => {
await expect(
collect(
service.streamChatResponse({
+ access: { accessScope: 'user', ownerId: userId },
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
mcpServerAttributions: runtime.mcpServerAttributions,
@@ -1931,6 +1987,7 @@ describe('AgentExecutionOrchestratorService', () => {
{ type: 'finish', finishReason: 'error' },
]);
const stream = service.streamChatResponse({
+ access: { accessScope: 'user', ownerId: userId },
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
mcpServerAttributions: runtime.mcpServerAttributions,
@@ -1979,7 +2036,7 @@ describe('AgentExecutionOrchestratorService', () => {
} as never);
await expect(
- service.getConversationHistory({ threadId: 'thread-1', projectId, agentId }),
+ service.getConversationHistory({ threadId: 'thread-1', projectId, agentId, userId }),
).resolves.toEqual([
{
id: 'execution-1:user',
@@ -2079,6 +2136,38 @@ describe('AgentExecutionOrchestratorService', () => {
);
});
+ it('rejects a private thread on the project checkpoint path', async () => {
+ const { service, checkpointStorage, executionService, runtimeCacheService } = makeService();
+ checkpointStorage.getStatus.mockResolvedValue({
+ status: 'active',
+ checkpoint: {
+ persistence: { threadId: 'thread-1', resourceId: 'task:task-1' },
+ },
+ } as never);
+ executionService.findThreadById.mockResolvedValue({
+ projectId,
+ agentId,
+ accessScope: 'user',
+ ownerId: userId,
+ } as never);
+
+ await expect(
+ collect(
+ service.resumeForChat({
+ agentId,
+ projectId,
+ runId: 'run-1',
+ toolCallId: 'tc-1',
+ resumeData: { approved: true },
+ usePublishedVersion: true,
+ integrationType: 'task',
+ }),
+ ),
+ ).rejects.toThrow('does not belong to this chat');
+ expect(runtimeCacheService.getRuntime).not.toHaveBeenCalled();
+ expect(executionService.startExecutionRecording).not.toHaveBeenCalled();
+ });
+
it.each([false, true])(
'installs an interaction on the checkpoint memory only after a successful claim: %s',
async (claimed) => {
@@ -2192,7 +2281,7 @@ describe('AgentExecutionOrchestratorService', () => {
{},
{
threadId: 'thread-1',
- resourceId: 'draft-chat:user-2',
+ resourceId: 'draft-chat:user-1',
hostMetadata: encodeAgentSandboxHostMetadata({
projectId,
principalHash: hashAgentSandboxPrincipal({
@@ -2478,6 +2567,7 @@ describe('AgentExecutionOrchestratorService', () => {
await collect(
service.streamChatResponse({
+ access: { accessScope: 'user', ownerId: userId },
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
mcpServerAttributions: runtime.mcpServerAttributions,
diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-thread.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-thread.repository.test.ts
index cd2ea1d1263..0c61a4793ff 100644
--- a/packages/cli/src/modules/agents/__tests__/agent-execution-thread.repository.test.ts
+++ b/packages/cli/src/modules/agents/__tests__/agent-execution-thread.repository.test.ts
@@ -7,6 +7,8 @@ import { mockEntityManager } from '@test/mocking';
import { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
import { AgentExecutionThreadRepository } from '../repositories/agent-execution-thread.repository';
+const access = { accessScope: 'project' as const, ownerId: null };
+
const entityManager = mockEntityManager(AgentExecutionThread);
const mockDataSource = { manager: entityManager };
@@ -46,6 +48,7 @@ describe('AgentExecutionThreadRepository', () => {
'agent-1',
'Support agent',
'project-1',
+ access,
);
expect(entityManager.transaction).toHaveBeenCalledWith('SERIALIZABLE', expect.any(Function));
@@ -55,6 +58,7 @@ describe('AgentExecutionThreadRepository', () => {
agentId: 'agent-1',
agentName: 'Support agent',
projectId: 'project-1',
+ ...access,
taskId: null,
taskVersionId: null,
sessionNumber: 8,
@@ -72,7 +76,7 @@ describe('AgentExecutionThreadRepository', () => {
return await callback(trx as never);
});
- await repository.findOrCreate('thread-1', 'agent-1', 'Support agent', 'project-1', {
+ await repository.findOrCreate('thread-1', 'agent-1', 'Support agent', 'project-1', access, {
parentThreadId: 'parent-thread-1',
parentAgentId: 'parent-agent-1',
});
@@ -82,6 +86,7 @@ describe('AgentExecutionThreadRepository', () => {
agentId: 'agent-1',
agentName: 'Support agent',
projectId: 'project-1',
+ ...access,
taskId: null,
taskVersionId: null,
sessionNumber: 8,
@@ -103,6 +108,7 @@ describe('AgentExecutionThreadRepository', () => {
'agent-1',
'Support agent',
'project-1',
+ access,
undefined,
'task-1',
'version-1',
@@ -134,6 +140,7 @@ describe('AgentExecutionThreadRepository', () => {
'agent-1',
'Support agent',
'project-1',
+ access,
);
expect(entityManager.transaction).toHaveBeenCalledTimes(2);
diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-update-broadcaster.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-update-broadcaster.test.ts
index bd193d13201..270b2448a80 100644
--- a/packages/cli/src/modules/agents/__tests__/agent-execution-update-broadcaster.test.ts
+++ b/packages/cli/src/modules/agents/__tests__/agent-execution-update-broadcaster.test.ts
@@ -1,9 +1,10 @@
import type { Logger } from '@n8n/backend-common';
-import type { ProjectRelationRepository } from '@n8n/db';
+import type { UserRepository } from '@n8n/db';
import type { InstanceSettings } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import type { Push } from '@/push';
+import type { RoleService } from '@/services/role.service';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import { AgentExecutionUpdateBroadcaster } from '../agent-execution-update-broadcaster';
@@ -19,18 +20,25 @@ const update = {
describe('AgentExecutionUpdateBroadcaster', () => {
const logger = mock();
- const projectRelationRepository = mock();
+ const userRepository = mock();
+ const roleService = mock();
const push = mock();
const publisher = mock();
const instanceSettings = mock();
const threadRepository = mock();
- const thread = mock({ projectId: 'project-1', agentId: 'agent-1' });
+ const thread = mock({
+ projectId: 'project-1',
+ agentId: 'agent-1',
+ accessScope: 'project',
+ ownerId: null,
+ });
let broadcaster: AgentExecutionUpdateBroadcaster;
beforeEach(() => {
vi.clearAllMocks();
logger.scoped.mockReturnValue(logger);
- projectRelationRepository.findUserIdsByProjectId.mockResolvedValue(['user-1', 'user-2']);
+ roleService.rolesWithScope.mockResolvedValue([]);
+ userRepository.findIdsWithGlobalOrProjectRoles.mockResolvedValue(['user-1', 'user-2']);
threadRepository.findOneBy.mockResolvedValue(thread);
Object.defineProperties(instanceSettings, {
isWorker: { value: false, configurable: true },
@@ -38,11 +46,12 @@ describe('AgentExecutionUpdateBroadcaster', () => {
});
broadcaster = new AgentExecutionUpdateBroadcaster(
logger,
- projectRelationRepository,
+ userRepository,
push,
publisher,
instanceSettings,
threadRepository,
+ roleService,
);
});
@@ -72,6 +81,22 @@ describe('AgentExecutionUpdateBroadcaster', () => {
expect(publisher.publishCommand).not.toHaveBeenCalled();
});
+ it('sends private execution and task updates only to the owner through the relay', async () => {
+ Object.defineProperty(instanceSettings, 'isWorker', { value: true, configurable: true });
+ threadRepository.findOneBy.mockResolvedValue({
+ ...thread,
+ accessScope: 'user',
+ ownerId: 'user-2',
+ });
+ broadcaster.notify(update);
+ broadcaster.notifyBackgroundJobsUpdated('agent-1', 'thread-1');
+ await vi.waitFor(() => expect(publisher.publishCommand).toHaveBeenCalledTimes(2));
+ for (const [command] of publisher.publishCommand.mock.calls) {
+ expect(command).toMatchObject({ payload: { userIds: ['user-2'] } });
+ }
+ for (const [, userIds] of push.sendToUsers.mock.calls) expect(userIds).toEqual(['user-2']);
+ });
+
it.each(['isWorker', 'isMultiMain'] as const)(
'delivers parent task updates locally and relays them with %s',
async (mode) => {
@@ -98,10 +123,12 @@ describe('AgentExecutionUpdateBroadcaster', () => {
});
it('skips task updates for projects without members', async () => {
- projectRelationRepository.findUserIdsByProjectId.mockResolvedValue([]);
+ userRepository.findIdsWithGlobalOrProjectRoles.mockResolvedValue([]);
broadcaster.notifyBackgroundJobsUpdated('agent-1', 'thread-1');
await vi.waitFor(() =>
- expect(projectRelationRepository.findUserIdsByProjectId).toHaveBeenCalledWith('project-1'),
+ expect(userRepository.findIdsWithGlobalOrProjectRoles).toHaveBeenCalledWith(
+ expect.objectContaining({ projectIds: ['project-1'] }),
+ ),
);
expect(push.sendToUsers).not.toHaveBeenCalled();
expect(publisher.publishCommand).not.toHaveBeenCalled();
@@ -130,7 +157,7 @@ describe('AgentExecutionUpdateBroadcaster', () => {
});
it('contains project lookup and relay failures', async () => {
- projectRelationRepository.findUserIdsByProjectId.mockRejectedValueOnce(
+ userRepository.findIdsWithGlobalOrProjectRoles.mockRejectedValueOnce(
new Error('lookup failed'),
);
broadcaster.notify(update);
diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts
index 5c4e6895e12..80740119823 100644
--- a/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts
+++ b/packages/cli/src/modules/agents/__tests__/agent-execution.service.test.ts
@@ -10,14 +10,20 @@ import type { Telemetry } from '@/telemetry';
import type { AgentChatAttachmentService } from '../agent-chat-attachment.service';
import { AgentExecutionService, type RecordMessageParams } from '../agent-execution.service';
import type { AgentExecutionUpdateBroadcaster } from '../agent-execution-update-broadcaster';
-import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
+import type {
+ AgentExecutionThread,
+ AgentThreadAccess,
+} from '../entities/agent-execution-thread.entity';
import type { AgentExecution } from '../entities/agent-execution.entity';
import type { MessageRecord, TimelineEvent } from '../execution-recorder';
import type { AgentExecutionLogStore } from '../execution-log/agent-execution-log-store';
+import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
import type { N8nMemory } from '../integrations/n8n-memory';
import type { AgentExecutionThreadRepository } from '../repositories/agent-execution-thread.repository';
import type { AgentExecutionRepository } from '../repositories/agent-execution.repository';
+const previewAccess = { accessScope: 'user' as const, ownerId: 'user-1' };
+
type N8nMemoryImplementation = ReturnType;
function makeThread(overrides: Partial = {}): AgentExecutionThread {
@@ -26,6 +32,7 @@ function makeThread(overrides: Partial = {}): AgentExecuti
agentId: 'agent-1',
agentName: 'Agent',
projectId: 'project-1',
+ ...previewAccess,
title: null,
emoji: null,
parentThreadId: null,
@@ -67,6 +74,7 @@ describe('AgentExecutionService', () => {
let storageConfig: Mocked;
let errorReporter: Mocked;
let agentChatAttachmentService: Mocked;
+ const checkpointStorage = mock();
let executionUpdateBroadcaster: Mocked;
const txRunner = mock();
@@ -99,14 +107,18 @@ describe('AgentExecutionService', () => {
storageConfig,
errorReporter,
executionUpdateBroadcaster,
+ checkpointStorage,
txRunner,
);
});
- async function recordExecution(params: RecordMessageParams): Promise {
+ async function recordExecution(
+ params: RecordMessageParams,
+ access: AgentThreadAccess = previewAccess,
+ ): Promise {
const { record, ...startParams } = params;
const executionId = await service.startExecutionRecording(
- startParams,
+ { ...startParams, access },
new Date(record.startTime),
);
return await service.finalizeExecution(executionId, params);
@@ -131,6 +143,7 @@ describe('AgentExecutionService', () => {
},
];
const params = {
+ access: previewAccess,
threadId: 'thread-1',
agentId: 'agent-1',
agentName: 'Agent',
@@ -176,6 +189,7 @@ describe('AgentExecutionService', () => {
const executionId = await service.startExecutionRecording(
{
+ access: previewAccess,
threadId: 'thread-1',
agentId: 'agent-1',
agentName: 'Agent',
@@ -221,6 +235,7 @@ describe('AgentExecutionService', () => {
});
agentExecutionRepository.save.mockResolvedValue(mock({ id: 'execution-1' }));
const params = {
+ access: previewAccess,
threadId: 'thread-1',
agentId: 'agent-1',
agentName: 'Agent',
@@ -436,6 +451,7 @@ describe('AgentExecutionService', () => {
storageConfig,
errorReporter,
executionUpdateBroadcaster,
+ checkpointStorage,
txRunner,
);
@@ -524,6 +540,7 @@ describe('AgentExecutionService', () => {
storageConfig,
errorReporter,
executionUpdateBroadcaster,
+ checkpointStorage,
txRunner,
);
@@ -626,6 +643,7 @@ describe('AgentExecutionService', () => {
'agent-1',
'Agent',
'project-1',
+ previewAccess,
{
parentThreadId: 'parent-thread-1',
parentAgentId: 'parent-agent-1',
@@ -636,30 +654,35 @@ describe('AgentExecutionService', () => {
});
it('stamps the task snapshot version on newly created task sessions', async () => {
+ const access: AgentThreadAccess = { accessScope: 'project', ownerId: null };
agentExecutionThreadRepository.findOrCreate.mockResolvedValue({
- thread: makeThread({ title: 'Task run' }),
+ thread: makeThread({ title: 'Task run', ...access }),
created: false,
});
agentExecutionRepository.create.mockImplementation((data) => data as AgentExecution);
agentExecutionRepository.save.mockResolvedValue({ id: 'execution-1' } as AgentExecution);
- await recordExecution({
- threadId: 'thread-1',
- agentId: 'agent-1',
- agentName: 'Agent',
- projectId: 'project-1',
- userMessage: 'Run task',
- record: makeMessageRecord(),
- source: 'task',
- taskId: 'task-1',
- taskVersionId: 'version-1',
- });
+ await recordExecution(
+ {
+ threadId: 'thread-1',
+ agentId: 'agent-1',
+ agentName: 'Agent',
+ projectId: 'project-1',
+ userMessage: 'Run task',
+ record: makeMessageRecord(),
+ source: 'task',
+ taskId: 'task-1',
+ taskVersionId: 'version-1',
+ },
+ access,
+ );
expect(agentExecutionThreadRepository.findOrCreate).toHaveBeenCalledWith(
'thread-1',
'agent-1',
'Agent',
'project-1',
+ access,
undefined,
'task-1',
'version-1',
@@ -960,6 +983,7 @@ describe('AgentExecutionService', () => {
storageConfig,
errorReporter,
executionUpdateBroadcaster,
+ checkpointStorage,
txRunner,
);
const partial = [{ type: 'text', content: 'Partial', timestamp: 1, endTime: 2 }] as const;
@@ -1037,7 +1061,7 @@ describe('AgentExecutionService', () => {
]),
);
- const result = await service.getThreads('project-1', 'agent-1', 20);
+ const result = await service.getThreads('project-1', 'agent-1', 'user-1', 20);
expect(result.threads).toEqual([
expect.objectContaining({ id: failedThread.id, failureSummary, status: 'error' }),
@@ -1063,7 +1087,7 @@ describe('AgentExecutionService', () => {
agentExecutionThreadRepository.findOneBy.mockResolvedValue(thread);
agentExecutionRepository.findByThreadIdOrdered.mockResolvedValue(executions);
- const result = await service.getThreadDetail('thread-1', 'project-1', 'agent-1');
+ const result = await service.getThreadDetail('thread-1', 'project-1', 'agent-1', 'user-1');
expect(result).toEqual({ thread, executions });
});
@@ -1080,7 +1104,7 @@ describe('AgentExecutionService', () => {
agentExecutionThreadRepository.findOneBy.mockResolvedValue(thread);
agentExecutionRepository.findByThreadIdOrdered.mockResolvedValue([execution]);
- const result = await service.getThreadDetail('thread-1', 'project-1', 'agent-1');
+ const result = await service.getThreadDetail('thread-1', 'project-1', 'agent-1', 'user-1');
expect(result?.executions[0]?.timeline).toEqual(partial);
});
@@ -1120,7 +1144,7 @@ describe('AgentExecutionService', () => {
new Map([['execution-2', { timeline: [fsEvent], version: 1 }]]),
);
- const result = await service.getThreadDetail('thread-1', 'project-1', 'agent-1');
+ const result = await service.getThreadDetail('thread-1', 'project-1', 'agent-1', 'user-1');
expect(agentExecutionLogStore.readMany).toHaveBeenCalledWith([
{ agentId: 'agent-1', threadId: 'thread-1', executionId: 'execution-2', storedAt: 'fs' },
@@ -1139,7 +1163,7 @@ describe('AgentExecutionService', () => {
agentExecutionLogStore.hasLocation.mockReturnValue(true);
agentExecutionLogStore.readMany.mockRejectedValue(new Error('fs read failed'));
- const result = await service.getThreadDetail('thread-1', 'project-1', 'agent-1');
+ const result = await service.getThreadDetail('thread-1', 'project-1', 'agent-1', 'user-1');
expect(result).not.toBeNull();
expect(result!.executions[0].timeline).toBeNull();
@@ -1149,12 +1173,14 @@ describe('AgentExecutionService', () => {
it.each([
{ name: 'project', thread: makeThread({ projectId: 'other-project' }) },
{ name: 'agent', thread: makeThread({ agentId: 'other-agent' }) },
+ { name: 'owner', thread: makeThread({ ownerId: 'other-user' }) },
+ { name: 'unresolved owner', thread: makeThread({ ownerId: null }) },
])('does not read executions for a thread outside the requested $name', async ({ thread }) => {
agentExecutionThreadRepository.findOneBy.mockResolvedValue(thread);
- const result = await service.getThreadDetail('thread-1', 'project-1', 'agent-1');
-
- expect(result).toBeNull();
+ await expect(
+ service.getThreadDetail('thread-1', 'project-1', 'agent-1', 'user-1'),
+ ).resolves.toBeNull();
expect(agentExecutionRepository.findByThreadIdOrdered).not.toHaveBeenCalled();
});
});
@@ -1188,13 +1214,14 @@ describe('AgentExecutionService', () => {
executionLogs: [],
});
- const result = await service.deleteThread('project-1', 'agent-1', 'thread-1');
+ const result = await service.deleteThread('project-1', 'agent-1', 'thread-1', 'user-1');
expect(result).toBe(true);
expect(agentExecutionThreadRepository.deleteSession).toHaveBeenCalledWith(
'project-1',
'agent-1',
'thread-1',
+ 'user-1',
{},
);
expect(n8nMemory.getImplementation).toHaveBeenCalledWith('agent-1');
@@ -1210,7 +1237,7 @@ describe('AgentExecutionService', () => {
executionLogs: [{ id: 'execution-1', storedAt: 'fs' }],
});
- const result = await service.deleteThread('project-1', 'agent-1', 'thread-1');
+ const result = await service.deleteThread('project-1', 'agent-1', 'thread-1', 'user-1');
expect(result).toBe(true);
expect(agentExecutionLogStore.delete).toHaveBeenCalledWith([
@@ -1221,7 +1248,7 @@ describe('AgentExecutionService', () => {
it('does not clean SDK memory when the execution thread is not found', async () => {
agentExecutionThreadRepository.deleteSession.mockResolvedValue(null);
- const result = await service.deleteThread('project-1', 'agent-1', 'thread-1');
+ const result = await service.deleteThread('project-1', 'agent-1', 'thread-1', 'user-1');
expect(result).toBe(false);
expect(n8nMemory.getImplementation).not.toHaveBeenCalled();
diff --git a/packages/cli/src/modules/agents/__tests__/agent-session-langsmith-export.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-session-langsmith-export.service.test.ts
index f6d2910f82e..370a879ee8b 100644
--- a/packages/cli/src/modules/agents/__tests__/agent-session-langsmith-export.service.test.ts
+++ b/packages/cli/src/modules/agents/__tests__/agent-session-langsmith-export.service.test.ts
@@ -4,6 +4,7 @@ import type { User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import { N8N_VERSION } from '@/constants';
+import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { AiService } from '@/services/ai.service';
import { AgentSessionLangSmithExportService } from '../agent-session-langsmith-export.service';
@@ -232,6 +233,37 @@ describe('AgentSessionLangSmithExportService', () => {
);
});
+ it('checks the caller for each child before sending a trace', async () => {
+ const { service, agentExecutionService, threadRepository } = setup();
+ agentExecutionService.getThreadDetail
+ .mockResolvedValueOnce({ thread: makeThread(), executions: [makeExecution()] })
+ .mockRejectedValueOnce(new NotFoundError('Thread not found'));
+ threadRepository.findByParentThreadId.mockResolvedValueOnce([
+ makeThread({
+ id: 'child-thread',
+ agentId: 'child-agent',
+ parentThreadId: 'parent-thread',
+ parentAgentId: 'parent-agent',
+ }),
+ ]);
+ await expect(service.exportSession(input)).rejects.toThrow(NotFoundError);
+ expect(agentExecutionService.getThreadDetail).toHaveBeenNthCalledWith(
+ 1,
+ 'parent-thread',
+ 'project-1',
+ 'parent-agent',
+ 'user-1',
+ );
+ expect(agentExecutionService.getThreadDetail).toHaveBeenNthCalledWith(
+ 2,
+ 'child-thread',
+ 'project-1',
+ 'child-agent',
+ 'user-1',
+ );
+ expect(batchIngestRunsMock).not.toHaveBeenCalled();
+ });
+
it('exports a complete redacted session tree with stable snapshot IDs', async () => {
const { service, agentExecutionService, threadRepository } = setup();
const parentDetail: ThreadDetail = {
diff --git a/packages/cli/src/modules/agents/__tests__/agent-test-run.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-test-run.service.test.ts
index 3d6f9d82c1f..cb6826e66b3 100644
--- a/packages/cli/src/modules/agents/__tests__/agent-test-run.service.test.ts
+++ b/packages/cli/src/modules/agents/__tests__/agent-test-run.service.test.ts
@@ -12,7 +12,6 @@ import type { AgentExecutionOrchestratorService } from '../agent-execution-orche
import type { AgentExecutionService } from '../agent-execution.service';
import { AgentTestRunService } from '../agent-test-run.service';
import type { AgentValidationService } from '../agent-validation.service';
-import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
const agentId = 'agent-1';
@@ -67,6 +66,7 @@ function makeService() {
const agentExecutionOrchestratorService = mock();
const n8nCheckpointStorage = mock();
agentExecutionService.findThreadById.mockResolvedValue(null);
+ agentExecutionService.canUsePreviewThread.mockResolvedValue(true);
agentValidationService.validateAgentIsRunnable.mockResolvedValue({ missing: [] });
return {
@@ -413,11 +413,7 @@ describe('AgentTestRunService', () => {
agentValidationService,
agentExecutionOrchestratorService,
} = makeService();
- agentExecutionService.findThreadById.mockResolvedValue({
- id: 'session-1',
- projectId: 'another-project',
- agentId,
- } as AgentExecutionThread);
+ agentExecutionService.canUsePreviewThread.mockResolvedValue(false);
await expect(
service.executeDraftRun({
diff --git a/packages/cli/src/modules/agents/__tests__/agent-threads.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-threads.controller.test.ts
index b18582ec5ae..b8f8dd66818 100644
--- a/packages/cli/src/modules/agents/__tests__/agent-threads.controller.test.ts
+++ b/packages/cli/src/modules/agents/__tests__/agent-threads.controller.test.ts
@@ -1,4 +1,9 @@
+import { mock } from 'vitest-mock-extended';
+
+import type { AgentExecutionService } from '../agent-execution.service';
+import type { AgentSessionLangSmithExportService } from '../agent-session-langsmith-export.service';
import { AgentThreadsController } from '../agent-threads.controller';
+import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
import {
getControllerMetadata,
expectProjectScopedAgentRoutes,
@@ -33,4 +38,31 @@ describe('AgentThreadsController route access scopes', () => {
])('%s uses %s', (handlerName, scope) => {
expect(routes.get(handlerName)?.accessScope?.scope).toBe(scope);
});
+
+ it('omits internal ownership fields from a thread response', async () => {
+ const agentExecutionService = mock();
+ const controller = new AgentThreadsController(
+ agentExecutionService,
+ mock(),
+ );
+ agentExecutionService.getThreadDetail.mockResolvedValue({
+ thread: mock({
+ id: 'thread-1',
+ ownerId: 'user-1',
+ accessScope: 'user',
+ owner: null,
+ }),
+ executions: [],
+ });
+
+ const result = await controller.getThread({
+ params: { projectId: 'project-1', agentId: 'agent-1', threadId: 'thread-1' },
+ user: { id: 'user-1' },
+ } as never);
+
+ expect(result.thread).toMatchObject({ id: 'thread-1' });
+ expect(result.thread).not.toHaveProperty('ownerId');
+ expect(result.thread).not.toHaveProperty('accessScope');
+ expect(result.thread).not.toHaveProperty('owner');
+ });
});
diff --git a/packages/cli/src/modules/agents/agent-chat-attachment.service.ts b/packages/cli/src/modules/agents/agent-chat-attachment.service.ts
index 82aeeda538a..1dacfa208fa 100644
--- a/packages/cli/src/modules/agents/agent-chat-attachment.service.ts
+++ b/packages/cli/src/modules/agents/agent-chat-attachment.service.ts
@@ -10,6 +10,13 @@ import type { Readable } from 'node:stream';
import { AgentChatAttachment } from './entities/agent-chat-attachment.entity';
import { AgentChatAttachmentRepository } from './repositories/agent-chat-attachment.repository';
+import { AgentExecutionThreadRepository } from './repositories/agent-execution-thread.repository';
+import {
+ draftChatMemoryResourceId,
+ isIntegrationMemoryResourceId,
+ isTaskRunMemoryResourceId,
+} from './utils/agent-memory-scope';
+import { threadBelongsTo } from './utils/agent-thread-access';
// Typed against `SourceType` so a drift from the `binary_data` schema enum
// (see `packages/@n8n/db/src/entities/binary-data-file.ts`) is a compile error.
@@ -62,6 +69,7 @@ export class AgentChatAttachmentService {
private readonly logger: Logger,
private readonly binaryDataService: BinaryDataService,
private readonly repository: AgentChatAttachmentRepository,
+ private readonly threadRepository: AgentExecutionThreadRepository,
) {}
async storeInbound(params: StoreInboundAttachmentParams): Promise {
@@ -110,12 +118,24 @@ export class AgentChatAttachmentService {
}
}
- /** Metadata lookup scoped to the owning agent + project; null when out of scope. */
+ /** Return metadata only when the caller can read the session. */
async getForAgent(
attachmentId: string,
- scope: { agentId: string; projectId: string },
+ scope: { agentId: string; projectId: string; userId: string },
): Promise {
- return await this.repository.findByIdForAgent(attachmentId, scope);
+ const attachment = await this.repository.findByIdForAgent(attachmentId, scope);
+ if (!attachment) return null;
+ const thread = await this.threadRepository.findOneBy({ id: attachment.threadId });
+ if (thread) {
+ return threadBelongsTo(thread, scope.projectId, scope.agentId, scope.userId)
+ ? attachment
+ : null;
+ }
+ return attachment.resourceId === draftChatMemoryResourceId(scope.userId) ||
+ isIntegrationMemoryResourceId(attachment.resourceId ?? undefined) ||
+ isTaskRunMemoryResourceId(attachment.resourceId ?? undefined)
+ ? attachment
+ : null;
}
async getStream(attachment: AgentChatAttachment): Promise {
diff --git a/packages/cli/src/modules/agents/agent-chat.controller.ts b/packages/cli/src/modules/agents/agent-chat.controller.ts
index 62488cdf32a..1a9455249cb 100644
--- a/packages/cli/src/modules/agents/agent-chat.controller.ts
+++ b/packages/cli/src/modules/agents/agent-chat.controller.ts
@@ -27,7 +27,8 @@ import {
} from './agent-chat-attachment.service';
import { AgentExecutionOrchestratorService } from './agent-execution-orchestrator.service';
import { AgentExecutionRecordingError } from './agent-execution-recording.error';
-import { AgentExecutionService, threadBelongsTo } from './agent-execution.service';
+import { AgentExecutionService } from './agent-execution.service';
+import { threadBelongsTo } from './utils/agent-thread-access';
import { messagesToDto } from './agent-message-mapper';
import { type FlushableResponse, initSseStream } from './agent-sse-stream';
import { AgentTestChatService, chatThreadId } from './agent-test-chat.service';
@@ -35,7 +36,10 @@ import { AgentTestRunService } from './agent-test-run.service';
import { AgentsService } from './agents.service';
import { AgentsBuilderService } from './builder/agents-builder.service';
import { AgentBackgroundJobService } from './background/agent-background-job.service';
-import { draftChatMemoryResourceId } from './utils/agent-memory-scope';
+import {
+ draftChatMemoryResourceId,
+ userIdFromDraftChatMemoryResourceId,
+} from './utils/agent-memory-scope';
import { resolveInboundMimeType } from './utils/inbound-attachments';
import { withOpenSuspensions } from './utils/messages-envelope';
@@ -129,6 +133,7 @@ export class AgentChatController {
const prepared = await this.agentTestRunService.prepareDraftRun({
agentId,
projectId,
+ user: req.user,
sessionId,
credentialProvider,
});
@@ -265,7 +270,7 @@ export class AgentChatController {
// A new preview session has no thread until its first execution starts.
if (!thread) return { tasks: [] };
- if (!threadBelongsTo(thread, projectId, agentId)) {
+ if (!threadBelongsTo(thread, projectId, agentId, req.user.id)) {
throw new NotFoundError(`Thread "${threadId}" not found`);
}
@@ -293,18 +298,36 @@ export class AgentChatController {
const { projectId, agentId, threadId } = req.params;
const agent = await this.agentsService.findById(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
- // getConversationHistory delegates to getThreadDetail, which validates
- // thread ownership against both projectId and agentId before returning
- // execution transcript data.
+ const thread = await this.agentExecutionService.findThreadById(threadId);
+ if (thread && !threadBelongsTo(thread, projectId, agentId, req.user.id)) {
+ throw new NotFoundError(`Thread "${threadId}" not found`);
+ }
const history = await this.agentExecutionOrchestratorService.getConversationHistory({
threadId,
projectId,
agentId,
+ userId: req.user.id,
});
const checkpoint = await this.agentsBuilderService.findOpenCheckpointForThread(
agentId,
threadId,
);
+ if (
+ checkpoint &&
+ (thread?.accessScope === 'project'
+ ? userIdFromDraftChatMemoryResourceId(checkpoint.persistence?.resourceId ?? '') !==
+ undefined
+ : checkpoint.persistence?.resourceId !== draftChatMemoryResourceId(req.user.id) ||
+ (!thread &&
+ !(await this.agentExecutionService.canUsePreviewThread(
+ threadId,
+ projectId,
+ agentId,
+ req.user.id,
+ ))))
+ ) {
+ throw new NotFoundError(`Thread "${threadId}" not found`);
+ }
if (!history) {
if (checkpoint) return withOpenSuspensions([], checkpoint);
throw new NotFoundError(`Thread "${threadId}" not found`);
@@ -322,12 +345,27 @@ export class AgentChatController {
const { projectId, agentId } = req.params;
const agent = await this.agentsService.findById(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
+ if (
+ !(await this.agentExecutionService.canUsePreviewThread(
+ chatThreadId(agentId, req.user.id),
+ projectId,
+ agentId,
+ req.user.id,
+ ))
+ ) {
+ throw new NotFoundError('Session not found');
+ }
const messages = await this.agentTestChatService.getTestChatMessages(agentId, req.user.id);
const checkpoint = await this.agentsBuilderService.findOpenCheckpointForThread(
agentId,
chatThreadId(agentId, req.user.id),
);
- return withOpenSuspensions(messagesToDto(messages), checkpoint);
+ return withOpenSuspensions(
+ messagesToDto(messages),
+ checkpoint?.persistence?.resourceId === draftChatMemoryResourceId(req.user.id)
+ ? checkpoint
+ : null,
+ );
}
@Get('/:agentId/chat/attachments/:attachmentId')
@@ -343,6 +381,7 @@ export class AgentChatController {
const attachment = await this.agentChatAttachmentService.getForAgent(attachmentId, {
agentId,
projectId,
+ userId: req.user.id,
});
if (!attachment) throw new NotFoundError(`Attachment "${attachmentId}" not found`);
@@ -396,6 +435,16 @@ export class AgentChatController {
const { projectId, agentId } = req.params;
const agent = await this.agentsService.findById(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
+ if (
+ !(await this.agentExecutionService.canUsePreviewThread(
+ chatThreadId(agentId, req.user.id),
+ projectId,
+ agentId,
+ req.user.id,
+ ))
+ ) {
+ throw new NotFoundError('Session not found');
+ }
await this.agentTestChatService.clearTestChatMessages(agentId, req.user.id);
return { ok: true };
}
diff --git a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts
index 189774a144d..a71b7b7a788 100644
--- a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts
+++ b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts
@@ -20,6 +20,13 @@ import type { AgentRunTelemetryType, IAgentConfigurationTelemetryProperties } fr
import { Telemetry } from '@/telemetry';
import { AgentExecutionService, type StartExecutionParams } from './agent-execution.service';
+import type { AgentThreadAccess } from './entities/agent-execution-thread.entity';
+import {
+ draftChatMemoryResourceId,
+ isTaskRunMemoryResourceId,
+ userIdFromDraftChatMemoryResourceId,
+} from './utils/agent-memory-scope';
+import { threadBelongsTo } from './utils/agent-thread-access';
import { AgentRunTracingService, modelIdFromSnapshot } from './agent-run-tracing.service';
import {
AgentRuntimeCacheService,
@@ -201,6 +208,7 @@ export interface ExecuteForWakeConfig {
}
export interface StreamChatResponseConfig {
+ access: AgentThreadAccess;
messageContext?: IntegrationMessageContext | null;
agentInstance: RuntimeAgent;
toolRegistry: ToolRegistry;
@@ -272,9 +280,15 @@ export class AgentExecutionOrchestratorService {
threadId: string;
projectId: string;
agentId: string;
+ userId: string;
}): Promise {
- const { threadId, projectId, agentId } = params;
- const detail = await this.agentExecutionService.getThreadDetail(threadId, projectId, agentId);
+ const { threadId, projectId, agentId, userId } = params;
+ const detail = await this.agentExecutionService.getThreadDetail(
+ threadId,
+ projectId,
+ agentId,
+ userId,
+ );
if (!detail) return null;
return executionsToMessagesDto(detail.executions);
}
@@ -300,6 +314,11 @@ export class AgentExecutionOrchestratorService {
) {
return false;
}
+ const thread = await this.agentExecutionService.findThreadById(checkpoint.persistence.threadId);
+ const userId = userIdFromDraftChatMemoryResourceId(params.resourceId);
+ if (thread && (!userId || !threadBelongsTo(thread, thread.projectId, params.agentId, userId))) {
+ return false;
+ }
const childCheckpoints = getDelegatedChildCheckpoints(checkpoint, params.agentId);
if (checkpointStatus.status === 'active') {
@@ -330,6 +349,7 @@ export class AgentExecutionOrchestratorService {
}): Promise<{
memoryScope: NonNullable;
sandboxPrincipalHash: AgentSandboxPrincipalHash | undefined;
+ access: AgentThreadAccess;
}> {
const { agentId, projectId, runId, expectedMemory, user, usePublishedVersion } = params;
const checkpointStatus = await this.n8nCheckpointStorage.getStatus(runId, agentId);
@@ -356,6 +376,34 @@ export class AgentExecutionOrchestratorService {
) {
throw new UserError(`Checkpoint ${runId} does not belong to this chat`);
}
+ const isPreview = !usePublishedVersion && !isTaskRunMemoryResourceId(memoryScope.resourceId);
+ let access: AgentThreadAccess = { accessScope: 'project', ownerId: null };
+ if (isPreview) {
+ if (
+ !user ||
+ memoryScope.resourceId !== draftChatMemoryResourceId(user.id) ||
+ !(await this.agentExecutionService.canUsePreviewThread(
+ memoryScope.threadId,
+ projectId,
+ agentId,
+ user.id,
+ ))
+ ) {
+ throw new UserError(`Checkpoint ${runId} does not belong to this chat`);
+ }
+ access = { accessScope: 'user', ownerId: user.id };
+ } else {
+ const thread = await this.agentExecutionService.findThreadById(memoryScope.threadId);
+ if (
+ userIdFromDraftChatMemoryResourceId(memoryScope.resourceId) ||
+ (thread &&
+ (thread.projectId !== projectId ||
+ thread.agentId !== agentId ||
+ thread.accessScope !== 'project'))
+ ) {
+ throw new UserError(`Checkpoint ${runId} does not belong to this chat`);
+ }
+ }
const sandboxScope = decodeAgentSandboxHostMetadata(memoryScope.hostMetadata);
const sandboxPrincipalHash = sandboxScope?.principalHash;
@@ -372,7 +420,7 @@ export class AgentExecutionOrchestratorService {
throw new UserError(`Checkpoint ${runId} is unavailable and cannot be resumed`);
}
- return { memoryScope, sandboxPrincipalHash };
+ return { memoryScope, sandboxPrincipalHash, access };
}
/**
@@ -395,7 +443,7 @@ export class AgentExecutionOrchestratorService {
onExecutionRecorded,
abortSignal,
} = config;
- const { memoryScope, sandboxPrincipalHash } = await this.loadResumeCheckpoint({
+ const { memoryScope, sandboxPrincipalHash, access } = await this.loadResumeCheckpoint({
agentId,
projectId,
runId,
@@ -419,7 +467,7 @@ export class AgentExecutionOrchestratorService {
...(sandboxPrincipalHash ? { sandboxPrincipalHash } : {}),
previewChat: config.previewChat,
},
- { threadId, userMessage: null, source, onExecutionRecorded, abortSignal },
+ { threadId, userMessage: null, source, onExecutionRecorded, abortSignal, access },
),
(runtime) =>
this.turnExecutionService.execute({
@@ -488,6 +536,7 @@ export class AgentExecutionOrchestratorService {
...(abortSignal ? { abortSignal } : {}),
},
recording: {
+ access,
threadId,
agentId,
agentName: runtime.agent.name,
@@ -529,6 +578,18 @@ export class AgentExecutionOrchestratorService {
type: 'n8n-user',
userId: user.id,
});
+ if (
+ memory.resourceId !== draftChatMemoryResourceId(user.id) ||
+ !(await this.agentExecutionService.canUsePreviewThread(
+ memory.threadId,
+ projectId,
+ agentId,
+ user.id,
+ ))
+ ) {
+ throw new UserError('Session not found');
+ }
+ const access: AgentThreadAccess = { accessScope: 'user', ownerId: user.id };
yield* this.withRuntimeLease(
async () =>
await this.getRuntimeOrRecordFailure(
@@ -542,6 +603,7 @@ export class AgentExecutionOrchestratorService {
},
{
threadId: memory.threadId,
+ access,
userMessage: message,
attachments,
source,
@@ -564,6 +626,7 @@ export class AgentExecutionOrchestratorService {
);
return this.streamChatResponse({
+ access,
messageContext,
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
@@ -627,6 +690,7 @@ export class AgentExecutionOrchestratorService {
author,
attachments,
source: integrationType,
+ access: { accessScope: 'project', ownerId: null },
},
),
async (runtime) => {
@@ -644,6 +708,7 @@ export class AgentExecutionOrchestratorService {
}
return this.streamChatResponse({
messageContext,
+ access: { accessScope: 'project', ownerId: null },
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
mcpServerAttributions: runtime.mcpServerAttributions,
@@ -694,6 +759,7 @@ export class AgentExecutionOrchestratorService {
source: 'task',
taskId,
taskVersionId,
+ access: { accessScope: 'project', ownerId: null },
},
),
(runtime) =>
@@ -708,6 +774,7 @@ export class AgentExecutionOrchestratorService {
source: 'task',
taskId,
taskVersionId,
+ access: { accessScope: 'project', ownerId: null },
telemetry: {
runType: 'production',
configuration: runtime.telemetryConfiguration,
@@ -741,7 +808,13 @@ export class AgentExecutionOrchestratorService {
sandboxPrincipalHash,
allowBackgroundTasks: false,
},
- { threadId: memory.threadId, userMessage: message, source: 'task', taskId },
+ {
+ threadId: memory.threadId,
+ userMessage: message,
+ source: 'task',
+ taskId,
+ access: { accessScope: 'project', ownerId: null },
+ },
),
(runtime) =>
this.streamChatResponse({
@@ -755,6 +828,7 @@ export class AgentExecutionOrchestratorService {
projectId: runtime.projectId,
source: 'task',
taskId,
+ access: { accessScope: 'project', ownerId: null },
telemetry: {
runType: 'test',
configuration: runtime.telemetryConfiguration,
@@ -767,6 +841,21 @@ export class AgentExecutionOrchestratorService {
async executeForWake(config: ExecuteForWakeConfig): Promise {
const { agentId, projectId, message, memory, identity, abortSignal } = config;
const isDraft = identity.type === 'draft';
+ const access: AgentThreadAccess = isDraft
+ ? { accessScope: 'user', ownerId: identity.user.id }
+ : { accessScope: 'project', ownerId: null };
+ if (
+ isDraft &&
+ (memory.resourceId !== draftChatMemoryResourceId(identity.user.id) ||
+ !(await this.agentExecutionService.canUsePreviewThread(
+ memory.threadId,
+ projectId,
+ agentId,
+ identity.user.id,
+ )))
+ ) {
+ throw new UserError('Session not found');
+ }
// Draft wakes skip the quota hook, like other test chat runs.
if (!isDraft) await this.externalHooks.run('agent.preExecute', [agentId]);
@@ -787,11 +876,18 @@ export class AgentExecutionOrchestratorService {
...(isDraft ? { user: identity.user } : {}),
sandboxPrincipalHash: identity.principalHash,
},
- { threadId: memory.threadId, userMessage: null, source: integrationType, abortSignal },
+ {
+ threadId: memory.threadId,
+ userMessage: null,
+ source: integrationType,
+ abortSignal,
+ access,
+ },
),
(runtime) =>
this.streamWakeResponse(
this.streamChatResponse({
+ access,
messageContext,
agentInstance: runtime.agent,
toolRegistry: runtime.toolRegistry,
@@ -953,6 +1049,7 @@ export class AgentExecutionOrchestratorService {
},
recording: {
threadId,
+ access: config.access,
agentId,
agentName: agentInstance.name,
projectId,
@@ -997,7 +1094,14 @@ export class AgentExecutionOrchestratorService {
params: GetRuntimeParams,
session: Pick<
StartExecutionParams,
- 'threadId' | 'userMessage' | 'author' | 'attachments' | 'source' | 'taskId' | 'taskVersionId'
+ | 'threadId'
+ | 'userMessage'
+ | 'author'
+ | 'attachments'
+ | 'source'
+ | 'taskId'
+ | 'taskVersionId'
+ | 'access'
> & {
onExecutionRecorded?: (executionId: string) => void;
abortSignal?: AbortSignal;
diff --git a/packages/cli/src/modules/agents/agent-execution-update-broadcaster.ts b/packages/cli/src/modules/agents/agent-execution-update-broadcaster.ts
index e647bc4dd27..af1991cf2ce 100644
--- a/packages/cli/src/modules/agents/agent-execution-update-broadcaster.ts
+++ b/packages/cli/src/modules/agents/agent-execution-update-broadcaster.ts
@@ -1,6 +1,6 @@
import type { PushPayload } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
-import { ProjectRelationRepository } from '@n8n/db';
+import { UserRepository } from '@n8n/db';
import { OnPubSubEvent } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
@@ -8,8 +8,11 @@ import { InstanceSettings } from 'n8n-core';
import { Push } from '@/push';
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
import { Publisher } from '@/scaling/pubsub/publisher.service';
+import { RoleService } from '@/services/role.service';
+import type { AgentExecutionThread } from './entities/agent-execution-thread.entity';
import { AgentExecutionThreadRepository } from './repositories/agent-execution-thread.repository';
+import { threadBelongsTo } from './utils/agent-thread-access';
type AgentExecutionUpdate = PushPayload<'agentExecutionUpdated'>;
@@ -17,11 +20,12 @@ type AgentExecutionUpdate = PushPayload<'agentExecutionUpdated'>;
export class AgentExecutionUpdateBroadcaster {
constructor(
private readonly logger: Logger,
- private readonly projectRelationRepository: ProjectRelationRepository,
+ private readonly userRepository: UserRepository,
private readonly push: Push,
private readonly publisher: Publisher,
private readonly instanceSettings: InstanceSettings,
private readonly threadRepository: AgentExecutionThreadRepository,
+ private readonly roleService: RoleService,
) {
this.logger = this.logger.scoped('agents');
}
@@ -37,7 +41,9 @@ export class AgentExecutionUpdateBroadcaster {
}
private async broadcast(data: AgentExecutionUpdate): Promise {
- const userIds = await this.projectRelationRepository.findUserIdsByProjectId(data.projectId);
+ const thread = await this.threadRepository.findOneBy({ id: data.threadId });
+ if (!thread || thread.projectId !== data.projectId || thread.agentId !== data.agentId) return;
+ const userIds = await this.getRecipients(thread);
if (userIds.length === 0) return;
this.push.sendToUsers({ type: 'agentExecutionUpdated', data }, userIds);
@@ -61,7 +67,7 @@ export class AgentExecutionUpdateBroadcaster {
if (!thread || thread.agentId !== agentId) return;
const data = { projectId: thread.projectId, agentId, threadId };
- const userIds = await this.projectRelationRepository.findUserIdsByProjectId(data.projectId);
+ const userIds = await this.getRecipients(thread);
if (userIds.length === 0) return;
this.push.sendToUsers({ type: 'agentBackgroundTasksUpdated', data }, userIds);
@@ -73,6 +79,19 @@ export class AgentExecutionUpdateBroadcaster {
}
}
+ private async getRecipients(thread: AgentExecutionThread): Promise {
+ const [globalRoleSlugs, projectRoleSlugs] = await Promise.all([
+ this.roleService.rolesWithScope('global', ['agent:read']),
+ this.roleService.rolesWithScope('project', ['agent:read']),
+ ]);
+ const userIds = await this.userRepository.findIdsWithGlobalOrProjectRoles({
+ projectIds: [thread.projectId],
+ projectRoleSlugs,
+ globalRoleSlugs,
+ });
+ return userIds.filter((id) => threadBelongsTo(thread, thread.projectId, thread.agentId, id));
+ }
+
@OnPubSubEvent('relay-agent-background-tasks-update', { instanceType: 'main' })
handleBackgroundJobsRelay({
data,
diff --git a/packages/cli/src/modules/agents/agent-execution.service.ts b/packages/cli/src/modules/agents/agent-execution.service.ts
index 796d90102b9..6dbb1cc0821 100644
--- a/packages/cli/src/modules/agents/agent-execution.service.ts
+++ b/packages/cli/src/modules/agents/agent-execution.service.ts
@@ -19,11 +19,17 @@ import {
type StoredAttachmentRef,
} from './agent-chat-attachment.service';
import { AgentExecutionUpdateBroadcaster } from './agent-execution-update-broadcaster';
-import { AgentExecutionThread } from './entities/agent-execution-thread.entity';
+import {
+ AgentExecutionThread,
+ type AgentThreadAccess,
+} from './entities/agent-execution-thread.entity';
import { AgentExecution, type AgentExecutionStatus } from './entities/agent-execution.entity';
import type { MessageRecord, TimelineEvent } from './execution-recorder';
import { AgentExecutionLogStore } from './execution-log/agent-execution-log-store';
import { N8nMemory } from './integrations/n8n-memory';
+import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage';
+import { draftChatMemoryResourceId } from './utils/agent-memory-scope';
+import { threadBelongsTo } from './utils/agent-thread-access';
import { AgentExecutionThreadRepository } from './repositories/agent-execution-thread.repository';
import type { AgentExecutionThreadMetadata } from './repositories/agent-execution-thread.repository';
import {
@@ -65,6 +71,7 @@ export interface RecordMessageParams {
}
export interface StartExecutionParams extends Omit {
+ access: AgentThreadAccess;
initialTimeline?: TimelineEvent[];
}
@@ -81,7 +88,11 @@ export interface ThreadDetail {
executions: AgentExecution[];
}
-export interface ThreadListItem extends Omit {
+export interface ThreadListItem
+ extends Omit<
+ AgentExecutionThread,
+ 'generateId' | 'setUpdateDate' | 'ownerId' | 'accessScope' | 'owner'
+ > {
firstMessage: string | null;
/** Earliest non-null execution source for the thread (e.g. slack, telegram). */
source: string | null;
@@ -120,6 +131,7 @@ export class AgentExecutionService {
private readonly storageConfig: StorageConfig,
private readonly errorReporter: ErrorReporter,
private readonly executionUpdateBroadcaster: AgentExecutionUpdateBroadcaster,
+ private readonly checkpointStorage: N8NCheckpointStorage,
private readonly txRunner: TransactionRunner,
) {}
@@ -375,6 +387,7 @@ export class AgentExecutionService {
params.agentId,
params.agentName,
params.projectId,
+ params.access,
params.threadMetadata,
params.taskId,
params.taskVersionId,
@@ -501,12 +514,18 @@ export class AgentExecutionService {
* cascades, so deleting the thread removes the runs in one statement.
* The transaction removes all database rows. External data is deleted after commit.
*/
- async deleteThread(projectId: string, agentId: string, threadId: string): Promise {
+ async deleteThread(
+ projectId: string,
+ agentId: string,
+ threadId: string,
+ userId: string,
+ ): Promise {
const refs = await this.txRunner.run({}, async (ctx) => {
const deletion = await this.agentExecutionThreadRepository.deleteSession(
projectId,
agentId,
threadId,
+ userId,
ctx,
);
if (!deletion) return null;
@@ -558,6 +577,7 @@ export class AgentExecutionService {
async getThreads(
projectId: string,
agentId: string,
+ userId: string,
limit: number,
cursor?: string,
filters: AgentSessionQueryFilters = {},
@@ -565,6 +585,7 @@ export class AgentExecutionService {
const page = await this.agentExecutionThreadRepository.findByProjectIdPaginated(
projectId,
agentId,
+ userId,
limit,
cursor,
filters,
@@ -584,13 +605,15 @@ export class AgentExecutionService {
return {
...page,
- threads: page.threads.map((t) => ({
- ...t,
- firstMessage: messageMap.get(t.id) ?? null,
- source: sourceMap.get(t.id) ?? null,
- failureSummary: failureSummaryMap.get(t.id) ?? null,
- status: toSessionStatus(latestStatusMap.get(t.id), failureSummaryMap.has(t.id)),
- })),
+ threads: page.threads.map(
+ ({ ownerId: _ownerId, accessScope: _accessScope, owner: _owner, ...t }) => ({
+ ...t,
+ firstMessage: messageMap.get(t.id) ?? null,
+ source: sourceMap.get(t.id) ?? null,
+ failureSummary: failureSummaryMap.get(t.id) ?? null,
+ status: toSessionStatus(latestStatusMap.get(t.id), failureSummaryMap.has(t.id)),
+ }),
+ ),
};
}
@@ -602,9 +625,11 @@ export class AgentExecutionService {
threadId: string,
projectId: string,
agentId: string,
+ userId: string,
): Promise {
const thread = await this.agentExecutionThreadRepository.findOneBy({ id: threadId });
- if (!thread || !threadBelongsTo(thread, projectId, agentId)) return null;
+ if (!thread) return null;
+ if (!threadBelongsTo(thread, projectId, agentId, userId)) return null;
const executions = await this.agentExecutionRepository.findByThreadIdOrdered(threadId);
await this.hydrateTimelines(agentId, threadId, executions);
@@ -663,6 +688,26 @@ export class AgentExecutionService {
return await this.agentExecutionThreadRepository.findOneBy({ id: threadId });
}
+ async canUsePreviewThread(
+ threadId: string,
+ projectId: string,
+ agentId: string,
+ userId: string,
+ ): Promise {
+ const thread = await this.findThreadById(threadId);
+ if (thread) {
+ return thread.accessScope === 'user' && threadBelongsTo(thread, projectId, agentId, userId);
+ }
+ const resourceId = draftChatMemoryResourceId(userId);
+ const memory = await this.n8nMemory.getImplementation(agentId).getThread(threadId);
+ if (memory && memory.resourceId !== resourceId) return false;
+ return await this.checkpointStorage.hasNoConflictingThreadResource(
+ agentId,
+ threadId,
+ resourceId,
+ );
+ }
+
/** Narrow refs to those whose data lives in a blob store, i.e. all but `db`. */
private toBlobRefs(refs: T[]) {
return refs.filter((r): r is T & { storedAt: StorageLocation } => r.storedAt !== 'db');
@@ -692,18 +737,3 @@ function executionStatus(record: MessageRecord): AgentExecution['status'] {
if (record.finishReason === 'cancelled') return 'cancelled';
return 'success';
}
-
-/**
- * True if `thread` belongs to the given project and agent.
- * Returns false for threads from a different project/agent so the caller can
- * reject the request instead of leaking/modifying unrelated thread data.
- */
-export function threadBelongsTo(
- thread: AgentExecutionThread,
- projectId: string,
- agentId: string,
-): boolean {
- if (thread.projectId !== projectId) return false;
- if (thread.agentId !== agentId) return false;
- return true;
-}
diff --git a/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts b/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts
index 47ac7348051..ba6e5d384a8 100644
--- a/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts
+++ b/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts
@@ -91,6 +91,7 @@ export class AgentSessionLangSmithExportService {
input.threadId,
input.projectId,
input.agentId,
+ input.user.id,
new Set(),
);
const draft = buildSessionRun(tree, `sessions/${tree.thread.id}`);
@@ -131,6 +132,7 @@ export class AgentSessionLangSmithExportService {
threadId: string,
projectId: string,
agentId: string,
+ userId: string,
visited: Set,
): Promise {
if (visited.has(threadId)) {
@@ -138,7 +140,12 @@ export class AgentSessionLangSmithExportService {
}
visited.add(threadId);
- const detail = await this.agentExecutionService.getThreadDetail(threadId, projectId, agentId);
+ const detail = await this.agentExecutionService.getThreadDetail(
+ threadId,
+ projectId,
+ agentId,
+ userId,
+ );
if (!detail) {
throw new NotFoundError(`Thread "${threadId}" not found`);
}
@@ -154,7 +161,9 @@ export class AgentSessionLangSmithExportService {
) {
throw new ConflictError('Agent session child link is invalid');
}
- children.push(await this.loadSessionTree(child.id, projectId, child.agentId, visited));
+ children.push(
+ await this.loadSessionTree(child.id, projectId, child.agentId, userId, visited),
+ );
}
return { ...detail, children };
diff --git a/packages/cli/src/modules/agents/agent-test-run.service.ts b/packages/cli/src/modules/agents/agent-test-run.service.ts
index e5ccf74f354..59d9f676943 100644
--- a/packages/cli/src/modules/agents/agent-test-run.service.ts
+++ b/packages/cli/src/modules/agents/agent-test-run.service.ts
@@ -22,7 +22,7 @@ import {
type ExecuteForChatConfig,
type ResumeForChatConfig,
} from './agent-execution-orchestrator.service';
-import { AgentExecutionService, threadBelongsTo } from './agent-execution.service';
+import { AgentExecutionService } from './agent-execution.service';
import { AgentValidationService } from './agent-validation.service';
import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage';
import { draftChatMemoryResourceId } from './utils/agent-memory-scope';
@@ -30,6 +30,7 @@ import { draftChatMemoryResourceId } from './utils/agent-memory-scope';
interface PrepareDraftRunInput {
agentId: string;
projectId: string;
+ user: User;
sessionId?: string;
credentialProvider: CredentialProvider;
}
@@ -158,12 +159,19 @@ export class AgentTestRunService {
async prepareDraftRun({
agentId,
projectId,
+ user,
sessionId,
credentialProvider,
}: PrepareDraftRunInput): Promise {
if (sessionId) {
- const existing = await this.agentExecutionService.findThreadById(sessionId);
- if (existing && !threadBelongsTo(existing, projectId, agentId)) {
+ if (
+ !(await this.agentExecutionService.canUsePreviewThread(
+ sessionId,
+ projectId,
+ agentId,
+ user.id,
+ ))
+ ) {
return { status: 'session_not_found' };
}
}
@@ -244,8 +252,14 @@ export class AgentTestRunService {
response,
...input
}: ResumeDraftRunInput): Promise {
- const existing = await this.agentExecutionService.findThreadById(sessionId);
- if (existing && !threadBelongsTo(existing, input.projectId, input.agentId)) {
+ if (
+ !(await this.agentExecutionService.canUsePreviewThread(
+ sessionId,
+ input.projectId,
+ input.agentId,
+ input.user.id,
+ ))
+ ) {
return { status: 'session_not_found' };
}
diff --git a/packages/cli/src/modules/agents/agent-threads.controller.ts b/packages/cli/src/modules/agents/agent-threads.controller.ts
index d22f5fa552a..176c447e9f9 100644
--- a/packages/cli/src/modules/agents/agent-threads.controller.ts
+++ b/packages/cli/src/modules/agents/agent-threads.controller.ts
@@ -28,6 +28,7 @@ export class AgentThreadsController {
return await this.agentExecutionService.getThreads(
req.params.projectId,
req.params.agentId,
+ req.user.id,
limit,
cursor,
filters,
@@ -43,11 +44,18 @@ export class AgentThreadsController {
req.params.threadId,
req.params.projectId,
req.params.agentId,
+ req.user.id,
);
if (!result) {
throw new NotFoundError(`Thread "${req.params.threadId}" not found`);
}
- return result;
+ const {
+ ownerId: _ownerId,
+ accessScope: _accessScope,
+ owner: _owner,
+ ...thread
+ } = result.thread;
+ return { ...result, thread };
}
@Post('/:agentId/threads/:threadId/langsmith-export')
@@ -70,7 +78,12 @@ export class AgentThreadsController {
req: AuthenticatedRequest<{ projectId: string; agentId: string; threadId: string }>,
) {
const { projectId, agentId, threadId } = req.params;
- const deleted = await this.agentExecutionService.deleteThread(projectId, agentId, threadId);
+ const deleted = await this.agentExecutionService.deleteThread(
+ projectId,
+ agentId,
+ threadId,
+ req.user.id,
+ );
if (!deleted) {
throw new NotFoundError(`Thread "${threadId}" not found`);
}
diff --git a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts
index 65c32d674ef..d07613f396f 100644
--- a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts
+++ b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts
@@ -577,6 +577,7 @@ export class AgentWorkflowExecutionService {
const runType: AgentRunTelemetryType = useDraftVersion ? 'test' : 'production';
const recordingParams: StartExecutionParams = {
+ access: { accessScope: 'project', ownerId: null },
threadId,
agentId,
agentName: agentData.schema?.name ?? agentData.name,
diff --git a/packages/cli/src/modules/agents/entities/agent-checkpoint.entity.ts b/packages/cli/src/modules/agents/entities/agent-checkpoint.entity.ts
index 9d655e27b65..b04b29f3dc0 100644
--- a/packages/cli/src/modules/agents/entities/agent-checkpoint.entity.ts
+++ b/packages/cli/src/modules/agents/entities/agent-checkpoint.entity.ts
@@ -21,6 +21,7 @@ export class AgentCheckpoint extends WithTimestamps {
nullable: true,
comment: 'SDK thread key from checkpoint state. Execution history is optional.',
})
+ @Index()
threadId: string | null;
@Column({ type: 'text', nullable: true })
diff --git a/packages/cli/src/modules/agents/entities/agent-execution-thread.entity.ts b/packages/cli/src/modules/agents/entities/agent-execution-thread.entity.ts
index a3af8b3cdbd..0619a4c1f7d 100644
--- a/packages/cli/src/modules/agents/entities/agent-execution-thread.entity.ts
+++ b/packages/cli/src/modules/agents/entities/agent-execution-thread.entity.ts
@@ -1,6 +1,6 @@
import { AGENT_TASK_ID_MAX_LENGTH } from '@n8n/api-types';
-import { Project, WithTimestampsAndStringId } from '@n8n/db';
-import { Column, Entity, Index, JoinColumn, ManyToOne } from '@n8n/typeorm';
+import { Project, User, WithTimestampsAndStringId } from '@n8n/db';
+import { Column, Entity, Index, JoinColumn, ManyToOne, type Relation } from '@n8n/typeorm';
import { AgentHistory } from './agent-history.entity';
import { Agent } from './agent.entity';
@@ -20,6 +20,22 @@ import { Agent } from './agent.entity';
*/
@Entity({ name: 'agent_execution_threads' })
export class AgentExecutionThread extends WithTimestampsAndStringId {
+ @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' })
+ @JoinColumn({ name: 'ownerId', foreignKeyConstraintName: 'FK_agent_execution_threads_owner' })
+ owner: Relation | null;
+
+ @Index()
+ @Column({ type: 'uuid', nullable: true, comment: 'User who started this private session' })
+ ownerId: string | null;
+
+ @Column({
+ type: 'varchar',
+ length: 16,
+ default: 'user',
+ comment: 'user: private session; project: shared integration, workflow, or task session',
+ })
+ accessScope: 'user' | 'project';
+
@ManyToOne(() => Agent, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'agentId' })
agent: Agent;
@@ -106,3 +122,5 @@ export class AgentExecutionThread extends WithTimestampsAndStringId {
@Column({ type: 'int', default: 0 })
totalDuration: number;
}
+
+export type AgentThreadAccess = Pick;
diff --git a/packages/cli/src/modules/agents/integrations/__tests__/n8n-checkpoint-storage.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/n8n-checkpoint-storage.test.ts
index 8533c674446..23aa71a4294 100644
--- a/packages/cli/src/modules/agents/integrations/__tests__/n8n-checkpoint-storage.test.ts
+++ b/packages/cli/src/modules/agents/integrations/__tests__/n8n-checkpoint-storage.test.ts
@@ -249,6 +249,47 @@ describe('N8NCheckpointStorage', () => {
);
});
+ describe('hasNoConflictingThreadResource', () => {
+ const checkpoint = (overrides: Partial = {}) =>
+ mock({
+ agentId: 'agent-1',
+ threadId: 'thread-1',
+ state: JSON.stringify(suspendedState),
+ ...overrides,
+ });
+
+ it.each([
+ { name: 'allows an unused thread ID', rows: [], expected: true },
+ { name: 'allows a matching checkpoint', rows: [checkpoint()], expected: true },
+ {
+ name: 'rejects another agent',
+ rows: [checkpoint({ agentId: 'agent-2' })],
+ expected: false,
+ },
+ {
+ name: 'rejects another resource',
+ rows: [
+ checkpoint({
+ state: JSON.stringify({
+ ...suspendedState,
+ persistence: { threadId: 'thread-1', resourceId: 'resource-2' },
+ }),
+ }),
+ ],
+ expected: false,
+ },
+ { name: 'ignores malformed state', rows: [checkpoint({ state: '{' })], expected: true },
+ { name: 'ignores cleared state', rows: [checkpoint({ state: null })], expected: true },
+ ])('$name', async ({ rows, expected }) => {
+ const { service, repository } = makeService();
+ repository.findRetainedByThreadId.mockResolvedValue(rows);
+
+ await expect(
+ service.hasNoConflictingThreadResource('agent-1', 'thread-1', 'resource-1'),
+ ).resolves.toBe(expected);
+ });
+ });
+
describe('findSuspendedForThread', () => {
const row = (runId: string, state: SerializableAgentState) =>
({
diff --git a/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts b/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts
index db0dbf6b041..681b7912025 100644
--- a/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts
+++ b/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts
@@ -174,6 +174,25 @@ export class N8NCheckpointStorage {
return null;
}
+ async hasNoConflictingThreadResource(
+ agentId: string,
+ threadId: string,
+ resourceId: string,
+ ): Promise {
+ const rows = await this.agentCheckpointRepository.findRetainedByThreadId(threadId);
+ for (const row of rows) {
+ if (!row.state) continue;
+ const state = jsonParse(row.state, { fallbackValue: null });
+ if (
+ state?.persistence?.threadId === threadId &&
+ (row.agentId !== agentId || state.persistence.resourceId !== resourceId)
+ ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
private parseSuspendedState(
state: string | null,
threadId: string,
diff --git a/packages/cli/src/modules/agents/repositories/agent-checkpoint.repository.ts b/packages/cli/src/modules/agents/repositories/agent-checkpoint.repository.ts
index 024eb3f80f5..267287ff908 100644
--- a/packages/cli/src/modules/agents/repositories/agent-checkpoint.repository.ts
+++ b/packages/cli/src/modules/agents/repositories/agent-checkpoint.repository.ts
@@ -24,6 +24,10 @@ export class AgentCheckpointRepository extends Repository {
});
}
+ async findRetainedByThreadId(threadId: string): Promise {
+ return await this.find({ where: { threadId } });
+ }
+
async findForSandboxReconciliation(agentId: string): Promise {
return await this.find({
where: { agentId, expired: false },
diff --git a/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts
index e7f6bfba4e1..c44a9a46b88 100644
--- a/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts
+++ b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts
@@ -4,12 +4,15 @@ import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/d
import { Service } from '@n8n/di';
import { DataSource, Not, type EntityManager, type SelectQueryBuilder } from '@n8n/typeorm';
import chunk from 'lodash/chunk';
-import { jsonParse } from 'n8n-workflow';
+import { jsonParse, UserError } from 'n8n-workflow';
import { AgentChatAttachment } from '../entities/agent-chat-attachment.entity';
import { AgentCheckpoint } from '../entities/agent-checkpoint.entity';
import { AgentExecution } from '../entities/agent-execution.entity';
-import { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
+import {
+ AgentExecutionThread,
+ type AgentThreadAccess,
+} from '../entities/agent-execution-thread.entity';
import {
getDelegatedChildCheckpoints,
type DelegatedChildCheckpoint,
@@ -48,6 +51,7 @@ export class AgentExecutionThreadRepository extends BaseRepository {
return await this.manager.transaction('SERIALIZABLE', async (entityManager) => {
const repository = entityManager.getRepository(AgentExecutionThread);
+ if (metadata?.parentThreadId) {
+ const parent = await repository.findOneBy({ id: metadata.parentThreadId });
+ if (parent) {
+ if (parent.projectId !== projectId || parent.agentId !== metadata.parentAgentId) {
+ throw new UserError('Session not found');
+ }
+ access = { accessScope: parent.accessScope, ownerId: parent.ownerId };
+ }
+ }
+ if (access.accessScope === 'user' && !access.ownerId) {
+ throw new UserError('Session not found');
+ }
const existing = await repository.findOneBy({ id: threadId });
if (existing) {
+ if (
+ existing.projectId !== projectId ||
+ existing.agentId !== agentId ||
+ existing.accessScope !== access.accessScope ||
+ existing.ownerId !== access.ownerId
+ ) {
+ throw new UserError('Session not found');
+ }
return { thread: existing, created: false };
}
@@ -100,6 +126,7 @@ export class AgentExecutionThreadRepository extends BaseRepository {
const manager = this.managerFor(ctx);
- const thread = await manager.findOneBy(AgentExecutionThread, {
- id: threadId,
- projectId,
- agentId,
+ const thread = await manager.findOne(AgentExecutionThread, {
+ where: [
+ { id: threadId, projectId, agentId, accessScope: 'project' },
+ { id: threadId, projectId, agentId, accessScope: 'user', ownerId: userId },
+ ],
});
if (!thread) return null;
diff --git a/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts b/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts
index 694acc87a1d..0cc763bca4e 100644
--- a/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts
+++ b/packages/cli/src/modules/agents/sub-agents/__tests__/delegate-sub-agent-tool.test.ts
@@ -22,6 +22,7 @@ import { formatSubAgentToolOutput } from '../format-sub-agent-tool-output';
import type { SubAgentRunResult, SubAgentRunner } from '../sub-agent-runner';
const projectId = 'project-1';
+const parentAgentId = 'parent-agent-1';
const source: SubAgentSource = {
agentId: 'agent-2',
@@ -72,6 +73,7 @@ describe('createN8nDelegateSubAgentTool', () => {
it('forwards inline sub-agent runtime options into delegate tool metadata', () => {
const resolveInlineSubAgentProviderTools = vi.fn().mockReturnValue([]);
const tool = createN8nDelegateSubAgentTool({
+ parentAgentId,
runner,
sourcesById: { 'agent-2': source },
projectId,
@@ -117,6 +119,7 @@ describe('createN8nDelegateSubAgentTool', () => {
incrementTokenCount: vi.fn(),
};
const tool = createN8nDelegateSubAgentTool({
+ parentAgentId,
runner,
sourcesById: { 'agent-2': source },
projectId,
@@ -168,6 +171,7 @@ describe('createN8nDelegateSubAgentTool', () => {
it('forwards the parent persistence scope to the runner', async () => {
const principalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-1' });
const tool = createN8nDelegateSubAgentTool({
+ parentAgentId,
runner,
sourcesById: { 'agent-2': source },
projectId,
@@ -199,6 +203,7 @@ describe('createN8nDelegateSubAgentTool', () => {
it('forwards the parent telemetry from the tool context to the foreground runner', async () => {
const tool = createN8nDelegateSubAgentTool({
+ parentAgentId,
runner,
sourcesById: { 'agent-2': source },
projectId,
@@ -226,6 +231,7 @@ describe('createN8nDelegateSubAgentTool', () => {
it('omits telemetry from the runner context when the parent run has none', async () => {
const tool = createN8nDelegateSubAgentTool({
+ parentAgentId,
runner,
sourcesById: { 'agent-2': source },
projectId,
@@ -244,6 +250,7 @@ describe('createN8nDelegateSubAgentTool', () => {
it('selects a configured n8n agent source by subAgentId', async () => {
const selectedSource: SubAgentSource = { agentId: 'agent-2' };
const tool = createN8nDelegateSubAgentTool({
+ parentAgentId,
runner,
sourcesById: {
'agent-2': selectedSource,
@@ -276,6 +283,7 @@ describe('createN8nDelegateSubAgentTool', () => {
it('returns a failed tool output when the foreground runner throws', async () => {
runner.run.mockRejectedValue(new Error('child failed'));
const tool = createN8nDelegateSubAgentTool({
+ parentAgentId,
runner,
sourcesById: { 'agent-2': source },
projectId,
@@ -343,6 +351,7 @@ describe('createN8nDelegateSubAgentTool', () => {
incrementTokenCount: vi.fn(),
};
const tool = createN8nDelegateSubAgentTool({
+ parentAgentId,
runner,
sourcesById: { 'agent-2': source },
projectId,
@@ -428,6 +437,7 @@ describe('createN8nDelegateSubAgentTool', () => {
])('finishes a configured child resume when the $name error is terminal', async ({ error }) => {
runner.resumeForeground.mockRejectedValue(error);
const tool = createN8nDelegateSubAgentTool({
+ parentAgentId,
runner,
sourcesById: { 'agent-2': source },
projectId,
@@ -477,6 +487,7 @@ describe('createN8nDelegateSubAgentTool', () => {
it('routes configured child cancellation without resuming the child', async () => {
const tool = createN8nDelegateSubAgentTool({
+ parentAgentId,
runner,
sourcesById: { 'agent-2': source },
projectId,
diff --git a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-runner.test.ts b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-runner.test.ts
index bea6ee20107..bab86d85ef7 100644
--- a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-runner.test.ts
+++ b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-runner.test.ts
@@ -161,6 +161,7 @@ describe('SubAgentRunner', () => {
it('resolves reconstruction from the container at run time', async () => {
await runner.run(spawnRequest, {
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -173,6 +174,7 @@ describe('SubAgentRunner', () => {
agentExecutionService.startExecutionRecording.mockResolvedValue('agent-execution-1');
agentExecutionService.finalizeExecution.mockResolvedValue('agent-execution-1');
const result = await runner.run(spawnRequest, {
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -198,7 +200,7 @@ describe('SubAgentRunner', () => {
toolCodeByName: runtimeSource.toolCodeByName,
skills: runtimeSource.skills,
runtimeProfile: 'sub-agent',
- parentAgentIdForDelegation: undefined,
+ parentAgentIdForDelegation: parentAgentId,
user: undefined,
});
expect(childAgent.close).toHaveBeenCalledTimes(1);
@@ -267,6 +269,7 @@ describe('SubAgentRunner', () => {
const onChunk = vi.fn();
const result = await runner.run(spawnRequest, {
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -291,6 +294,7 @@ describe('SubAgentRunner', () => {
const result = await runner.run(
{ ...spawnRequest, childThreadId: 'pre-minted-thread' },
{
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -308,6 +312,7 @@ describe('SubAgentRunner', () => {
it('records the child turn with the parent run type, not its own published state', async () => {
const result = await runner.run(spawnRequest, {
+ parentAgentId,
projectId,
credentialProvider,
runType: 'test',
@@ -329,7 +334,7 @@ describe('SubAgentRunner', () => {
])(
'resolves the child draft for test runs and the published version for production ($runType)',
async ({ runType, usePublishedVersion }) => {
- await runner.run(spawnRequest, { projectId, credentialProvider, runType });
+ await runner.run(spawnRequest, { parentAgentId, projectId, credentialProvider, runType });
expect(sourceResolver.resolveForRuntime).toHaveBeenCalledWith(spawnRequest.source, {
projectId,
@@ -342,6 +347,7 @@ describe('SubAgentRunner', () => {
'reconstructs child workflow tools with the parent %s execution mode',
async (workflowToolExecutionMode) => {
await runner.run(spawnRequest, {
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -358,6 +364,7 @@ describe('SubAgentRunner', () => {
const user = mock({ id: 'user-1' });
await runner.run(spawnRequest, {
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -373,6 +380,7 @@ describe('SubAgentRunner', () => {
const result = await runner.run(
{ ...spawnRequest, parentResourceId: 'draft-chat:user-1' },
{
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -397,6 +405,7 @@ describe('SubAgentRunner', () => {
const result = await runner.run(
{ ...spawnRequest, parentSandboxPrincipalHash: principalHash },
{
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -437,6 +446,7 @@ describe('SubAgentRunner', () => {
parentThreadId,
},
{
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -451,6 +461,7 @@ describe('SubAgentRunner', () => {
it('threads the parent workspace handle into child reconstruction with the delegation thread id', async () => {
const parentWorkspaceHandle = mock();
const result = await runner.run(spawnRequest, {
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -474,6 +485,7 @@ describe('SubAgentRunner', () => {
parentThreadId,
},
{
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -569,6 +581,7 @@ describe('SubAgentRunner', () => {
await expect(
runner.run(spawnRequest, {
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -757,6 +770,7 @@ describe('SubAgentRunner', () => {
resumeContext: { agentId: 'agent-1', versionId: 'version-7' },
},
{
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -814,6 +828,7 @@ describe('SubAgentRunner', () => {
await expect(
runner.run(spawnRequest, {
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -830,7 +845,12 @@ describe('SubAgentRunner', () => {
const cause = new Error('recording unavailable');
agentExecutionService[operation].mockRejectedValue(cause);
await expect(
- runner.run(spawnRequest, { projectId, credentialProvider, runType: 'production' }),
+ runner.run(spawnRequest, {
+ parentAgentId,
+ projectId,
+ credentialProvider,
+ runType: 'production',
+ }),
).rejects.toMatchObject({
phase: operation === 'startExecutionRecording' ? 'create' : 'finalize',
executionStarted: operation === 'finalizeExecution',
@@ -850,7 +870,12 @@ describe('SubAgentRunner', () => {
const error = new Error('child initialization failed');
reconstructionService.reconstructFromResolvedSource.mockRejectedValue(error);
await expect(
- runner.run(spawnRequest, { projectId, credentialProvider, runType: 'production' }),
+ runner.run(spawnRequest, {
+ parentAgentId,
+ projectId,
+ credentialProvider,
+ runType: 'production',
+ }),
).rejects.toBe(error);
expect(childAgent.stream).not.toHaveBeenCalled();
expect(agentExecutionService.finalizeExecution).toHaveBeenCalledWith(
@@ -877,6 +902,7 @@ describe('SubAgentRunner', () => {
);
const run = runner.run(spawnRequest, {
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -904,6 +930,7 @@ describe('SubAgentRunner', () => {
};
await runner.run(spawnRequest, {
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
@@ -925,6 +952,7 @@ describe('SubAgentRunner', () => {
it('omits telemetry from the child stream call when the parent context has none', async () => {
await runner.run(spawnRequest, {
+ parentAgentId,
projectId,
credentialProvider,
runType: 'production',
diff --git a/packages/cli/src/modules/agents/sub-agents/sub-agent-runner.ts b/packages/cli/src/modules/agents/sub-agents/sub-agent-runner.ts
index 4bef136a3b8..b3108d89bcc 100644
--- a/packages/cli/src/modules/agents/sub-agents/sub-agent-runner.ts
+++ b/packages/cli/src/modules/agents/sub-agents/sub-agent-runner.ts
@@ -56,7 +56,7 @@ import { SubAgentSourceResolver } from './sub-agent-source-resolver';
export interface SubAgentRunContext {
projectId: string;
/** Saved n8n agent id of the delegating parent agent, used to link the child session back. */
- parentAgentId?: string;
+ parentAgentId: string;
credentialProvider: CredentialProvider;
/**
* Telemetry classification inherited from the delegating parent run.
@@ -211,6 +211,8 @@ export class SubAgentRunner {
const userMessage =
operation.type === 'run' ? renderDelegateSubAgentPrompt(operation.request) : null;
const recording: StartExecutionParams = {
+ // Saved parents supply access in the thread creation transaction.
+ access: { accessScope: 'user', ownerId: null },
threadId,
agentId: runtimeSource.source.sourceId,
agentName: runtimeSource.source.config.name,
diff --git a/packages/cli/src/modules/agents/utils/agent-thread-access.ts b/packages/cli/src/modules/agents/utils/agent-thread-access.ts
new file mode 100644
index 00000000000..ae525a0e802
--- /dev/null
+++ b/packages/cli/src/modules/agents/utils/agent-thread-access.ts
@@ -0,0 +1,15 @@
+import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
+
+export function threadBelongsTo(
+ thread: AgentExecutionThread,
+ projectId: string,
+ agentId: string,
+ userId: string,
+): boolean {
+ return (
+ thread.projectId === projectId &&
+ thread.agentId === agentId &&
+ (thread.accessScope === 'project' ||
+ (thread.accessScope === 'user' && thread.ownerId !== null && thread.ownerId === userId))
+ );
+}
diff --git a/packages/cli/src/modules/instance-ai/__tests__/agent-preview-handoff.test.ts b/packages/cli/src/modules/instance-ai/__tests__/agent-preview-handoff.test.ts
index 2e5029e14fd..f8daec27c03 100644
--- a/packages/cli/src/modules/instance-ai/__tests__/agent-preview-handoff.test.ts
+++ b/packages/cli/src/modules/instance-ai/__tests__/agent-preview-handoff.test.ts
@@ -45,10 +45,16 @@ describe('resolveAgentPreviewHandoff', () => {
const result = await resolveAgentPreviewHandoff(handoff, {
projectId: 'project-1',
+ userId: 'user-1',
getThreadDetail,
});
- expect(getThreadDetail).toHaveBeenCalledWith('preview-thread-1', 'project-1', 'agent-1');
+ expect(getThreadDetail).toHaveBeenCalledWith(
+ 'preview-thread-1',
+ 'project-1',
+ 'agent-1',
+ 'user-1',
+ );
expect(result.titleFallback).toBe('Support triage test');
expect(result.target).toEqual({ agentId: 'agent-1', projectId: 'project-1' });
expect(result.block.startsWith(AGENT_PREVIEW_CONTEXT_OPEN_TAG)).toBe(true);
@@ -68,6 +74,7 @@ describe('resolveAgentPreviewHandoff', () => {
{ ...handoff, agentName: 'SEO Auditor', agentIcon: 'search', sessionTitle: 'Help with tone' },
{
projectId: 'project-1',
+ userId: 'user-1',
getThreadDetail: vi.fn().mockResolvedValue({
thread: makeThread(),
executions: [makeExecution()],
@@ -83,6 +90,7 @@ describe('resolveAgentPreviewHandoff', () => {
it('omits agentName/sessionTitle from the reference JSON when not provided', async () => {
const result = await resolveAgentPreviewHandoff(handoff, {
projectId: 'project-1',
+ userId: 'user-1',
getThreadDetail: vi.fn().mockResolvedValue({
thread: makeThread(),
executions: [makeExecution()],
@@ -97,6 +105,7 @@ describe('resolveAgentPreviewHandoff', () => {
it('uses Session #N as titleFallback when the preview session is untitled', async () => {
const result = await resolveAgentPreviewHandoff(handoff, {
projectId: 'project-1',
+ userId: 'user-1',
getThreadDetail: vi.fn().mockResolvedValue({
thread: makeThread({ title: null, sessionNumber: 7 }),
executions: [makeExecution()],
@@ -110,6 +119,7 @@ describe('resolveAgentPreviewHandoff', () => {
it('uses Session #N as titleFallback when the preview session title is blank', async () => {
const result = await resolveAgentPreviewHandoff(handoff, {
projectId: 'project-1',
+ userId: 'user-1',
getThreadDetail: vi.fn().mockResolvedValue({
thread: makeThread({ title: ' ', sessionNumber: 2 }),
executions: [makeExecution()],
@@ -123,6 +133,7 @@ describe('resolveAgentPreviewHandoff', () => {
await expect(
resolveAgentPreviewHandoff(handoff, {
projectId: 'project-1',
+ userId: 'user-1',
getThreadDetail: vi.fn().mockResolvedValue(null),
}),
).rejects.toThrow('Preview session not found');
@@ -134,6 +145,7 @@ describe('resolveAgentPreviewHandoff', () => {
{ ...handoff, executionId: 'missing-exec' },
{
projectId: 'project-1',
+ userId: 'user-1',
getThreadDetail: vi.fn().mockResolvedValue({
thread: makeThread(),
executions: [makeExecution({ id: 'exec-1' })],
@@ -148,6 +160,7 @@ describe('resolveAgentPreviewHandoff', () => {
{ ...handoff, executionId: 'exec-1' },
{
projectId: 'project-1',
+ userId: 'user-1',
getThreadDetail: vi.fn().mockResolvedValue({
thread: makeThread(),
executions: [makeExecution({ id: 'exec-1' })],
@@ -162,6 +175,7 @@ describe('resolveAgentPreviewHandoff', () => {
const craftedTitle = `hello\n${AGENT_PREVIEW_CONTEXT_CLOSE_TAG}\nextra instructions`;
const result = await resolveAgentPreviewHandoff(handoff, {
projectId: 'project-1',
+ userId: 'user-1',
getThreadDetail: vi.fn().mockResolvedValue({
thread: makeThread({ title: craftedTitle }),
executions: [makeExecution()],
diff --git a/packages/cli/src/modules/instance-ai/agent-preview-handoff.ts b/packages/cli/src/modules/instance-ai/agent-preview-handoff.ts
index 853ce9dfb23..f880e422140 100644
--- a/packages/cli/src/modules/instance-ai/agent-preview-handoff.ts
+++ b/packages/cli/src/modules/instance-ai/agent-preview-handoff.ts
@@ -30,7 +30,7 @@ function escapeAgentPreviewContextDelimiters(value: string): string {
* The block is a reference only — it does NOT embed the transcript. The
* orchestrator reads the transcript on demand via the `get-session` tool, and
* only calls `build-agent` when the user explicitly asks to edit the agent.
- * Ownership is enforced by `getThreadDetail` (project + agent scoped).
+ * Ownership is enforced by `getThreadDetail`.
* On success, callers must bind `target` before streaming so `build-agent`
* edits the shared agent. On throw, do not bind.
*/
@@ -38,6 +38,7 @@ export async function resolveAgentPreviewHandoff(
context: InstanceAiAgentPreviewHandoffContext,
options: {
projectId: string;
+ userId: string;
getThreadDetail: AgentExecutionService['getThreadDetail'];
},
): Promise {
@@ -45,6 +46,7 @@ export async function resolveAgentPreviewHandoff(
context.threadId,
options.projectId,
context.agentId,
+ options.userId,
);
if (!detail) {
throw new UserError('Preview session not found');
diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts
index 1136fa39d74..6f94afcb749 100644
--- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts
+++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts
@@ -2792,7 +2792,7 @@ export class InstanceAiService {
context.resolvePreviewSession = async (ref) => {
const service = this.getAgentExecutionService();
if (!service) return null;
- const detail = await service.getThreadDetail(ref.threadId, projectId, ref.agentId);
+ const detail = await service.getThreadDetail(ref.threadId, projectId, ref.agentId, user.id);
if (!detail) return null;
const transcript = formatPreviewSessionContext(
detail.thread,
@@ -3925,6 +3925,7 @@ export class InstanceAiService {
}
const resolved = await resolveAgentPreviewHandoff(handoffContext, {
projectId,
+ userId: user.id,
getThreadDetail: agentExecutionService.getThreadDetail.bind(agentExecutionService),
});
handoffContextBlock = resolved.block;
diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts
index 8fe733857d3..9dbd12287fc 100644
--- a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts
+++ b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts
@@ -1253,7 +1253,7 @@ export class McpAgentToolsService {
});
const previewAccessNote = canOpenPreview
? undefined
- : 'Your access permits running this agent but not opening Preview. Share the Preview URL with a project member who has project and agent read access.';
+ : 'Your access permits running this agent but not opening Preview. Ask a project administrator for project and agent read access to open your preview session.';
const cancelled = await this.agentTestRunService.cancelSuspendedRuns({
agentId,
suspensions: result.suspensions,
diff --git a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts
index 09d23534860..b23459352ac 100644
--- a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts
+++ b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts
@@ -10,8 +10,8 @@ import {
type StreamChunk,
} from '@n8n/agents';
import { createTeamProject, mockLogger, testDb, testModules } from '@n8n/backend-test-utils';
-import { AgentsConfig } from '@n8n/config';
-import { TransactionRunner } from '@n8n/db';
+import { AgentsConfig, AiConfig } from '@n8n/config';
+import { TransactionRunner, type User } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource, EntityManager } from '@n8n/typeorm';
import { generateNanoId } from '@n8n/utils/generate-nano-id';
@@ -25,6 +25,16 @@ import { mock } from 'vitest-mock-extended';
import { z } from 'zod';
import type { Telemetry } from '@/telemetry';
+import type { ExternalHooks } from '@/external-hooks';
+import { AgentExecutionOrchestratorService } from '@/modules/agents/agent-execution-orchestrator.service';
+import type { AgentRuntimeCacheService } from '@/modules/agents/agent-runtime-cache.service';
+import type { AgentRunTracingService } from '@/modules/agents/agent-run-tracing.service';
+import type { AgentSandboxRuntimeService } from '@/modules/agents/agent-sandbox-runtime.service';
+import {
+ encodeAgentSandboxHostMetadata,
+ hashAgentSandboxPrincipal,
+} from '@/modules/agents/agent-sandbox-principal';
+import type { IntegrationMessageContextService } from '@/modules/agents/integrations/integration-message-context.service';
import type { AgentChatAttachmentService } from '@/modules/agents/agent-chat-attachment.service';
import { AgentExecutionService } from '@/modules/agents/agent-execution.service';
import type { AgentExecutionUpdateBroadcaster } from '@/modules/agents/agent-execution-update-broadcaster';
@@ -35,7 +45,7 @@ import type { AgentWakeService } from '@/modules/agents/background/agent-wake.se
import { ExecutionRecorder, type TimelineEvent } from '@/modules/agents/execution-recorder';
import type { AgentExecutionLogStore } from '@/modules/agents/execution-log/agent-execution-log-store';
import { N8NCheckpointStorage } from '@/modules/agents/integrations/n8n-checkpoint-storage';
-import type { N8nMemory } from '@/modules/agents/integrations/n8n-memory';
+import { N8nMemory } from '@/modules/agents/integrations/n8n-memory';
import { AgentCheckpointRepository } from '@/modules/agents/repositories/agent-checkpoint.repository';
import { AgentChatAttachment } from '@/modules/agents/entities/agent-chat-attachment.entity';
import type { AgentExecutionThread } from '@/modules/agents/entities/agent-execution-thread.entity';
@@ -46,6 +56,8 @@ import { AgentExecutionThreadRepository } from '@/modules/agents/repositories/ag
import { AgentExecutionRepository } from '@/modules/agents/repositories/agent-execution.repository';
import { AgentRepository } from '@/modules/agents/repositories/agent.repository';
+import { createMember, createAdmin } from '../../shared/db/users';
+
describe('AgentExecutionRepository', () => {
let repository: AgentExecutionRepository;
let threadRepo: AgentExecutionThreadRepository;
@@ -89,7 +101,7 @@ describe('AgentExecutionRepository', () => {
await testDb.terminate();
});
- function recordingServices(memoryBackend = mock>()) {
+ function recordingServices(memoryBackend: ReturnType = mock()) {
const memory = mock();
memory.getImplementation.mockReturnValue(memoryBackend);
const attachmentService = mock();
@@ -105,6 +117,7 @@ describe('AgentExecutionRepository', () => {
mock({ modeTag: 'db' }),
mock(),
mock(),
+ Container.get(N8NCheckpointStorage),
Container.get(TransactionRunner),
);
return {
@@ -188,7 +201,7 @@ describe('AgentExecutionRepository', () => {
});
}
- function createApprovalAgentFactory(threadId: string) {
+ function createApprovalAgentFactory(threadId: string, ownerId?: string, approvals = 1) {
type ModelStreamPart = Awaited<
ReturnType
>['stream'] extends ReadableStream
@@ -200,7 +213,14 @@ describe('AgentExecutionRepository', () => {
modelId: 'recorded-turn',
doStream: async () => {
expect(await repository.existsRunningByThread(threadId)).toBe(true);
- const first = modelCalls++ === 0;
+ if (ownerId) {
+ expect(await threadRepo.findOneByOrFail({ id: threadId })).toMatchObject({
+ accessScope: 'user',
+ ownerId,
+ });
+ }
+ const call = modelCalls++;
+ const first = call < approvals;
return {
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
@@ -208,7 +228,7 @@ describe('AgentExecutionRepository', () => {
? [
{
type: 'tool-call' as const,
- toolCallId: 'approval-1',
+ toolCallId: `approval-${call + 1}`,
toolName: 'approve',
input: '{}',
},
@@ -246,10 +266,13 @@ describe('AgentExecutionRepository', () => {
return { action, makeAgent };
}
- async function startSuspendedApprovalRun() {
- const { turns } = recordingServices();
+ async function startSuspendedApprovalRun(user?: User, approvals = 1) {
+ const { turns, executionService } = recordingServices();
const threadId = uuid();
const recording = {
+ access: user
+ ? { accessScope: 'user' as const, ownerId: user.id }
+ : { accessScope: 'project' as const, ownerId: null },
threadId,
agentId,
agentName: 'Test Agent',
@@ -258,7 +281,7 @@ describe('AgentExecutionRepository', () => {
};
const checkpointRepo = Container.get(AgentCheckpointRepository);
const storage = new N8NCheckpointStorage(checkpointRepo, mockLogger(), new AgentsConfig());
- const { action, makeAgent } = createApprovalAgentFactory(threadId);
+ const { action, makeAgent } = createApprovalAgentFactory(threadId, user?.id, approvals);
const common = {
toolRegistry: new Map(),
mcpServerAttributions: new Map(),
@@ -272,7 +295,23 @@ describe('AgentExecutionRepository', () => {
prepare: async () => ({
type: 'start',
input: 'Start',
- options: { persistence: { threadId, resourceId: 'user-1' } },
+ options: {
+ persistence: {
+ threadId,
+ resourceId: user ? `draft-chat:${user.id}` : 'user-1',
+ ...(user
+ ? {
+ hostMetadata: encodeAgentSandboxHostMetadata({
+ projectId,
+ principalHash: hashAgentSandboxPrincipal({
+ type: 'n8n-user',
+ userId: user.id,
+ }),
+ }),
+ }
+ : {}),
+ },
+ },
recording,
}),
}),
@@ -286,6 +325,7 @@ describe('AgentExecutionRepository', () => {
return {
action,
+ executionService,
checkpointRepo,
common,
makeAgent,
@@ -420,11 +460,16 @@ describe('AgentExecutionRepository', () => {
]);
const before = await checkpointRepo.find({ order: { runId: 'ASC' } });
const otherProject = await createTeamProject();
+ const userId = uuid();
- expect(await executionService.deleteThread(projectId, otherAgent.id, threadId)).toBe(false);
- expect(await executionService.deleteThread(otherProject.id, agentId, threadId)).toBe(false);
+ expect(await executionService.deleteThread(projectId, otherAgent.id, threadId, userId)).toBe(
+ false,
+ );
+ expect(await executionService.deleteThread(otherProject.id, agentId, threadId, userId)).toBe(
+ false,
+ );
expect(await checkpointRepo.find({ order: { runId: 'ASC' } })).toEqual(before);
- expect(await executionService.deleteThread(projectId, agentId, threadId)).toBe(true);
+ expect(await executionService.deleteThread(projectId, agentId, threadId, userId)).toBe(true);
for (const runId of [suspension.runId, retainedRunId, childRunId, nestedRunId]) {
expect(await checkpointRepo.findByRunId(runId)).toBeNull();
@@ -471,7 +516,7 @@ describe('AgentExecutionRepository', () => {
}));
for (const batch of chunk(childCheckpoints, 400)) await checkpointRepo.insert(batch);
- expect(await executionService.deleteThread(projectId, agentId, threadId)).toBe(true);
+ expect(await executionService.deleteThread(projectId, agentId, threadId, uuid())).toBe(true);
expect(await checkpointRepo.findByRunId(suspension.runId)).toBeNull();
expect(await checkpointRepo.find({ where: { threadId: childThreadId } })).toEqual([]);
});
@@ -487,7 +532,7 @@ describe('AgentExecutionRepository', () => {
const interceptor = insertAttachmentAfterSnapshot(late);
try {
- expect(await executionService.deleteThread(projectId, agentId, thread.id)).toBe(true);
+ expect(await executionService.deleteThread(projectId, agentId, thread.id, uuid())).toBe(true);
} finally {
interceptor.mockRestore();
}
@@ -512,9 +557,9 @@ describe('AgentExecutionRepository', () => {
recordingServices(memoryBackend);
const executionsBefore = await repository.findByThreadIdOrdered(threadId);
- await expect(executionService.deleteThread(projectId, agentId, threadId)).rejects.toThrow(
- 'Memory cleanup failed',
- );
+ await expect(
+ executionService.deleteThread(projectId, agentId, threadId, uuid()),
+ ).rejects.toThrow('Memory cleanup failed');
expect(await checkpointRepo.findByRunId(suspension.runId)).not.toBeNull();
expect(await threadRepo.findOneBy({ id: threadId })).not.toBeNull();
@@ -548,9 +593,107 @@ describe('AgentExecutionRepository', () => {
});
});
+ it.each([false, true])(
+ 'keeps a preview resume unchanged for another caller with sandbox=%s',
+ async (sandboxEnabled) => {
+ const owner = await createMember();
+ const other = await createAdmin();
+ const fixture = await startSuspendedApprovalRun(owner, 2);
+ const { storage, checkpointRepo, executionService, turns, threadId, suspension } = fixture;
+ const runtimeCache = mock();
+ const runtime = fixture.makeAgent(storage.getStorage(agentId));
+ runtimeCache.getRuntime.mockResolvedValue({
+ agent: runtime,
+ toolRegistry: new Map(),
+ mcpServerAttributions: new Map(),
+ projectId,
+ agentId,
+ toolAccessCheckedAt: Date.now(),
+ telemetryConfiguration: {
+ model: 'mock',
+ channels: [],
+ tool_types: [],
+ tool_count: 0,
+ num_skills: 0,
+ memory_type: 'none',
+ },
+ });
+ const orchestrator = new AgentExecutionOrchestratorService(
+ mockLogger(),
+ storage,
+ executionService,
+ turns,
+ mock(),
+ runtimeCache,
+ mock(),
+ mock(),
+ mock(),
+ mock({ isEnabled: () => sandboxEnabled }),
+ agentRepo,
+ new AiConfig(),
+ );
+ const resume = async (
+ user: User,
+ runId = suspension.runId,
+ toolCallId = suspension.toolCallId,
+ ) =>
+ await collect(
+ orchestrator.resumeForChat({
+ agentId,
+ projectId,
+ runId,
+ toolCallId,
+ resumeData: { approved: true },
+ user,
+ usePublishedVersion: false,
+ }),
+ );
+ try {
+ const checkpoint = await checkpointRepo.findByRunId(suspension.runId);
+ const executions = await repository.findByThreadIdOrdered(threadId);
+ await expect(resume(other)).rejects.toThrow('does not belong to this chat');
+ expect(await checkpointRepo.findByRunId(suspension.runId)).toEqual(checkpoint);
+ expect(await repository.findByThreadIdOrdered(threadId)).toEqual(executions);
+ expect(runtimeCache.getRuntime).not.toHaveBeenCalled();
+ expect(fixture.action).not.toHaveBeenCalled();
+
+ await threadRepo.delete({ id: threadId });
+ await expect(resume(other)).rejects.toThrow('does not belong to this chat');
+ expect(await checkpointRepo.findByRunId(suspension.runId)).toEqual(checkpoint);
+ expect(await repository.findByThreadIdOrdered(threadId)).toEqual([]);
+ expect(await threadRepo.findOneBy({ id: threadId })).toBeNull();
+ expect(
+ await executionService.canUsePreviewThread(threadId, projectId, agentId, owner.id),
+ ).toBe(true);
+
+ const resumed = await resume(owner);
+ const next = resumed.find((chunk) => chunk.type === 'tool-call-suspended');
+ if (!next || next.type !== 'tool-call-suspended')
+ throw new Error('Expected next suspension');
+ await resume(owner, next.runId, next.toolCallId);
+ expect(fixture.action).toHaveBeenCalledTimes(2);
+ expect(await threadRepo.findOneByOrFail({ id: threadId })).toMatchObject({
+ accessScope: 'user',
+ ownerId: owner.id,
+ });
+ const continued = await repository.findByThreadIdOrdered(threadId);
+ expect(continued).toHaveLength(2);
+ for (const execution of continued) {
+ expect(execution.status).toBe('success');
+ expect(execution.timeline).toContainEqual(
+ expect.objectContaining({ type: 'hitl-response' }),
+ );
+ }
+ } finally {
+ await runtime.close();
+ }
+ },
+ );
+
it('recovers a failed finalization from the saved timeline and rejects a later terminal update', async () => {
const { executionService, turns } = recordingServices();
const params = {
+ access: { accessScope: 'project' as const, ownerId: null },
threadId: uuid(),
agentId,
agentName: 'Test Agent',
@@ -611,6 +754,7 @@ describe('AgentExecutionRepository', () => {
const createThread = async (overrides: Partial = {}) => {
const thread = threadRepo.create({
+ accessScope: 'project',
id: uuid(),
agentId,
agentName: 'Test Agent',
@@ -631,6 +775,123 @@ describe('AgentExecutionRepository', () => {
return await repository.save(execution);
};
+ it('filters private sessions before pagination and preserves their owner on reuse', async () => {
+ const owner = await createMember();
+ const other = await createAdmin();
+ const { executionService } = recordingServices();
+ const access = { accessScope: 'user' as const, ownerId: owner.id };
+ const { thread } = await threadRepo.findOrCreate(
+ uuid(),
+ agentId,
+ 'Test Agent',
+ projectId,
+ access,
+ );
+ await threadRepo.update(thread.id, { updatedAt: new Date('2026-01-03T00:00:00Z') });
+ const shared = await createThread({
+ sessionNumber: 2,
+ updatedAt: new Date('2026-01-01T00:00:00Z'),
+ });
+ await createThread({
+ accessScope: 'user',
+ ownerId: other.id,
+ sessionNumber: 3,
+ updatedAt: new Date('2026-01-02T00:00:00Z'),
+ });
+ await createThread({
+ accessScope: 'user',
+ ownerId: null,
+ sessionNumber: 4,
+ updatedAt: new Date('2026-01-04T00:00:00Z'),
+ });
+ await createExecution({ threadId: thread.id, userMessage: 'Private message' });
+
+ const first = await executionService.getThreads(projectId, agentId, owner.id, 1);
+ const second = await executionService.getThreads(
+ projectId,
+ agentId,
+ owner.id,
+ 1,
+ first.nextCursor ?? undefined,
+ );
+ expect(first.threads.map(({ id }) => id)).toEqual([thread.id]);
+ expect(first.threads[0]).not.toHaveProperty('ownerId');
+ expect(first.threads[0]).not.toHaveProperty('accessScope');
+ expect(second.threads.map(({ id }) => id)).toEqual([shared.id]);
+ expect(second.nextCursor).toBeNull();
+ expect(
+ (await executionService.getThreadDetail(thread.id, projectId, agentId, owner.id))?.executions,
+ ).toHaveLength(1);
+ await expect(
+ executionService.getThreadDetail(thread.id, projectId, agentId, other.id),
+ ).resolves.toBeNull();
+ expect(await executionService.deleteThread(projectId, agentId, thread.id, other.id)).toBe(
+ false,
+ );
+ for (const incompatible of [
+ { accessScope: 'user' as const, ownerId: other.id },
+ { accessScope: 'project' as const, ownerId: null },
+ ]) {
+ await expect(
+ threadRepo.findOrCreate(thread.id, agentId, 'Test Agent', projectId, incompatible),
+ ).rejects.toThrow('Session not found');
+ }
+ expect(
+ await threadRepo.findOrCreate(thread.id, agentId, 'Test Agent', projectId, access),
+ ).toMatchObject({ created: false, thread: access });
+ const child = await threadRepo.findOrCreate(
+ uuid(),
+ agentId,
+ 'Test Agent',
+ projectId,
+ { accessScope: 'user', ownerId: null },
+ { parentThreadId: thread.id, parentAgentId: agentId },
+ );
+ expect(child.thread).toMatchObject(access);
+ const sharedChild = await threadRepo.findOrCreate(
+ uuid(),
+ agentId,
+ 'Test Agent',
+ projectId,
+ { accessScope: 'user', ownerId: null },
+ { parentThreadId: shared.id, parentAgentId: agentId },
+ );
+ expect(sharedChild.thread).toMatchObject({ accessScope: 'project', ownerId: null });
+ });
+
+ it.each(['custom', 'legacy'])('uses persisted ownership for a %s preview ID', async (format) => {
+ const owner = await createMember();
+ const other = await createAdmin();
+ const memory = Container.get(N8nMemory).getImplementation(agentId);
+ const { executionService } = recordingServices(memory);
+ const threadId = format === 'legacy' ? `test-${agentId}:${other.id}` : 'test-demo';
+ expect(await executionService.canUsePreviewThread(threadId, projectId, agentId, owner.id)).toBe(
+ true,
+ );
+ await memory.saveThread({ id: threadId, resourceId: `draft-chat:${owner.id}` });
+ expect(await executionService.canUsePreviewThread(threadId, projectId, agentId, owner.id)).toBe(
+ true,
+ );
+ expect(await executionService.canUsePreviewThread(threadId, projectId, agentId, other.id)).toBe(
+ false,
+ );
+ await createThread({ id: threadId, accessScope: 'user', ownerId: owner.id });
+ expect(await executionService.canUsePreviewThread(threadId, projectId, agentId, owner.id)).toBe(
+ true,
+ );
+ expect(await executionService.canUsePreviewThread(threadId, projectId, agentId, other.id)).toBe(
+ false,
+ );
+ await threadRepo.update(threadId, { ownerId: null });
+ expect(await executionService.canUsePreviewThread(threadId, projectId, agentId, owner.id)).toBe(
+ false,
+ );
+ expect(await memory.getThread(threadId)).toMatchObject({
+ resourceId: `draft-chat:${owner.id}`,
+ });
+ expect(await repository.findByThreadIdOrdered(threadId)).toEqual([]);
+ });
+
describe('findFirstUserMessageByThreadIds', () => {
// The repository builds a raw SQL fragment referencing camelCase columns.
// Postgres folds unquoted identifiers to lowercase, so this regression
@@ -836,9 +1097,16 @@ describe('AgentExecutionRepository', () => {
const idsFor = async (status: AgentSessionStatus) =>
(
- await threadRepo.findByProjectIdPaginated(projectId, agentId, 20, undefined, {
- status,
- })
+ await threadRepo.findByProjectIdPaginated(
+ projectId,
+ agentId,
+ '00000000-0000-4000-8000-000000000001',
+ 20,
+ undefined,
+ {
+ status,
+ },
+ )
).threads.map(({ id }) => id);
expect(await idsFor('running')).toEqual([running.id]);
@@ -899,6 +1167,7 @@ describe('AgentExecutionRepository', () => {
const result = await threadRepo.findByProjectIdPaginated(
projectId,
agentId,
+ '00000000-0000-4000-8000-000000000001',
20,
undefined,
{ origin },
@@ -927,6 +1196,7 @@ describe('AgentExecutionRepository', () => {
const firstPage = await threadRepo.findByProjectIdPaginated(
projectId,
agentId,
+ '00000000-0000-4000-8000-000000000001',
1,
undefined,
filters,
@@ -934,6 +1204,7 @@ describe('AgentExecutionRepository', () => {
const secondPage = await threadRepo.findByProjectIdPaginated(
projectId,
agentId,
+ '00000000-0000-4000-8000-000000000001',
1,
firstPage.nextCursor ?? undefined,
filters,
diff --git a/packages/cli/test/migration/1790014447117-add-agent-thread-ownership.test.ts b/packages/cli/test/migration/1790014447117-add-agent-thread-ownership.test.ts
new file mode 100644
index 00000000000..8ea8a0eb91a
--- /dev/null
+++ b/packages/cli/test/migration/1790014447117-add-agent-thread-ownership.test.ts
@@ -0,0 +1,184 @@
+import {
+ createTestMigrationContext,
+ initDbUpToMigration,
+ runSingleMigration,
+ undoLastSingleMigration,
+ type TestMigrationContext,
+} from '@n8n/backend-test-utils';
+import { DbConnection } from '@n8n/db';
+import { Container } from '@n8n/di';
+import { DataSource } from '@n8n/typeorm';
+import { randomUUID } from 'node:crypto';
+
+const migrationName = 'AddAgentThreadOwnership1790014447117';
+
+describe('Agent thread ownership migration', () => {
+ let dataSource: DataSource;
+ const ownerId = randomUUID();
+ const projectId = randomUUID();
+ const agentId = randomUUID();
+ const now = new Date('2026-09-01T12:00:00Z');
+ const fixtures = [
+ { id: 'memory', resource: `draft-chat:${ownerId}`, ownerId, accessScope: 'user' },
+ { id: `test-${agentId}:${ownerId}`, accessScope: 'user' },
+ { id: `test-${agentId}:workflow`, source: 'workflow', accessScope: 'project' },
+ { id: 'missing-user', resource: `draft-chat:${randomUUID()}`, accessScope: 'user' },
+ { id: 'unknown', accessScope: 'user' },
+ { id: 'integration', resource: 'integration:slack:U123', accessScope: 'project' },
+ { id: 'workflow', source: 'workflow', accessScope: 'project' },
+ { id: 'task', taskId: 'task-1', accessScope: 'project' },
+ { id: 'task-source', source: 'task', accessScope: 'project' },
+ { id: 'schedule-source', source: 'schedule', accessScope: 'project' },
+ { id: 'preview-source', taskId: 'task-2', source: 'mcp', accessScope: 'user' },
+ {
+ id: 'private-child',
+ source: 'subagent',
+ parentThreadId: 'memory',
+ parentAgentId: agentId,
+ ownerId,
+ accessScope: 'user',
+ },
+ {
+ id: 'project-child',
+ source: 'subagent',
+ parentThreadId: 'workflow',
+ parentAgentId: agentId,
+ accessScope: 'project',
+ },
+ {
+ id: 'invalid-child',
+ source: 'subagent',
+ parentThreadId: 'memory',
+ parentAgentId: randomUUID(),
+ accessScope: 'user',
+ },
+ {
+ id: 'mixed',
+ resource: `draft-chat:${ownerId}`,
+ source: 'slack',
+ ownerId,
+ accessScope: 'user',
+ },
+ ];
+
+ async function withContext(fn: (context: TestMigrationContext) => Promise): Promise {
+ const context = createTestMigrationContext(dataSource);
+ try {
+ return await fn(context);
+ } finally {
+ await context.queryRunner.release();
+ }
+ }
+
+ beforeAll(async () => {
+ await Container.get(DbConnection).init();
+ dataSource = Container.get(DataSource);
+ await withContext(async ({ queryRunner }) => await queryRunner.clearDatabase());
+ await initDbUpToMigration(migrationName);
+ await withContext(async ({ escape, runQuery }) => {
+ const table = escape.tableName;
+ await runQuery(`INSERT INTO ${table('user')} ("id") VALUES (:ownerId)`, { ownerId });
+ await runQuery(
+ `INSERT INTO ${table('project')} ("id", "name", "type", "createdAt", "updatedAt") VALUES (:projectId, 'Project', 'team', :now, :now)`,
+ { projectId, now },
+ );
+ await runQuery(
+ `INSERT INTO ${table('agents')} ("id", "name", "projectId", "integrations", "tools", "skills", "createdAt", "updatedAt") VALUES (:agentId, 'Agent', :projectId, '[]', '{}', '{}', :now, :now)`,
+ { agentId, projectId, now },
+ );
+ for (const fixture of fixtures) {
+ await runQuery(
+ `INSERT INTO ${table('agent_execution_threads')} ("id", "agentId", "agentName", "projectId", "taskId", "parentThreadId", "parentAgentId", "createdAt", "updatedAt") VALUES (:id, :agentId, 'Agent', :projectId, :taskId, :parentThreadId, :parentAgentId, :now, :now)`,
+ {
+ id: fixture.id,
+ agentId,
+ projectId,
+ taskId: fixture.taskId ?? null,
+ parentThreadId: fixture.parentThreadId ?? null,
+ parentAgentId: fixture.parentAgentId ?? null,
+ now,
+ },
+ );
+ await runQuery(
+ `INSERT INTO ${table('agent_execution')} ("id", "threadId", "status", "source", "createdAt", "updatedAt") VALUES (:id, :threadId, 'success', :source, :now, :now)`,
+ {
+ id: randomUUID(),
+ threadId: fixture.id,
+ source: fixture.source ?? null,
+ now,
+ },
+ );
+ if (fixture.resource) {
+ await runQuery(
+ `INSERT INTO ${table('agents_threads')} ("id", "resourceId") VALUES (:id, :resourceId)`,
+ { id: fixture.id, resourceId: fixture.resource },
+ );
+ }
+ }
+ });
+ });
+
+ afterAll(async () => await Container.get(DbConnection).close());
+
+ it('backfills session access and preserves execution rows', async () => {
+ await runSingleMigration(migrationName);
+ dataSource = Container.get(DataSource);
+ await withContext(async ({ escape, runQuery, queryRunner, tablePrefix }) => {
+ const threads = await runQuery<
+ Array<{ id: string; ownerId: string | null; accessScope: string }>
+ >(
+ `SELECT "id", "ownerId", "accessScope" FROM ${escape.tableName('agent_execution_threads')}`,
+ );
+ for (const fixture of fixtures) {
+ expect(threads.find((thread) => thread.id === fixture.id)).toEqual({
+ id: fixture.id,
+ ownerId: fixture.ownerId ?? null,
+ accessScope: fixture.accessScope,
+ });
+ }
+ expect(
+ await runQuery(`SELECT "threadId" FROM ${escape.tableName('agent_execution')}`),
+ ).toHaveLength(fixtures.length);
+ const table = await queryRunner.getTable(`${tablePrefix}agent_execution_threads`);
+ expect(table?.indices.some((index) => index.columnNames.includes('ownerId'))).toBe(true);
+ expect(table?.foreignKeys.filter((key) => key.columnNames.includes('ownerId'))).toHaveLength(
+ 1,
+ );
+ const checkpointTable = await queryRunner.getTable(`${tablePrefix}agent_checkpoints`);
+ expect(
+ checkpointTable?.indices.some(
+ (index) => index.columnNames.length === 1 && index.columnNames[0] === 'threadId',
+ ),
+ ).toBe(true);
+ });
+
+ await undoLastSingleMigration();
+ dataSource = Container.get(DataSource);
+ await withContext(async ({ escape, runQuery, queryRunner, tablePrefix }) => {
+ const table = await queryRunner.getTable(`${tablePrefix}agent_execution_threads`);
+ expect(
+ table?.columns.some((column) => column.name === 'ownerId' || column.name === 'accessScope'),
+ ).toBe(false);
+ expect(
+ await runQuery(`SELECT "threadId" FROM ${escape.tableName('agent_execution')}`),
+ ).toHaveLength(fixtures.length);
+ const checkpointTable = await queryRunner.getTable(`${tablePrefix}agent_checkpoints`);
+ expect(
+ checkpointTable?.indices.some(
+ (index) => index.columnNames.length === 1 && index.columnNames[0] === 'threadId',
+ ),
+ ).toBe(false);
+ });
+
+ await runSingleMigration(migrationName);
+ dataSource = Container.get(DataSource);
+ await withContext(async ({ escape, runQuery }) => {
+ await runQuery(`DELETE FROM ${escape.tableName('user')} WHERE "id" = :ownerId`, { ownerId });
+ expect(
+ await runQuery(
+ `SELECT "ownerId", "accessScope" FROM ${escape.tableName('agent_execution_threads')} WHERE "id" = 'memory'`,
+ ),
+ ).toEqual([{ ownerId: null, accessScope: 'user' }]);
+ });
+ });
+});