From baa38dbdc6570544e3a0474cdca4a929b76b8693 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Fri, 16 Oct 2026 07:45:16 +0000 Subject: [PATCH] feat(core): Add owned-session queries to the Agents thread repositories (no-changelog) (#40615) Co-authored-by: Claude Opus 5.5 --- .../agent-execution-thread.repository.ts | 142 ++++++- .../agent-message-queue.repository.ts | 9 + .../agent-thread-grant.repository.ts | 4 + .../agent-thread-repository-queries.test.ts | 381 ++++++++++++++++++ 4 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/integration/agents/agent-thread-repository-queries.test.ts 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 8de7657370f..4a5ff5a5289 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 @@ -2,9 +2,9 @@ import type { AgentSessionOrigin, AgentSessionQueryFilters } from '@n8n/api-type import type { SerializableAgentState } from '@n8n/agents'; import { BaseRepository, - TransactionRunner, escapeLike, LIKE_ESCAPE_CLAUSE, + TransactionRunner, type OperationContext, } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -41,6 +41,27 @@ export interface AgentExecutionThreadPage { nextCursor: string | null; } +/** Position of the last thread on a keyset page: `updatedAt`, then `id` breaks ties. */ +export interface AgentExecutionThreadKeyset { + updatedAt: Date; + id: string; +} + +export interface AgentExecutionThreadKeysetPage { + threads: AgentExecutionThread[]; + hasMore: boolean; +} + +function ownedSessionWhere(agentId: string, ownerId: string, threadId: string) { + return { + id: threadId, + agentId, + ownerId, + accessScope: 'user' as const, + parentThreadId: IsNull(), + }; +} + interface AgentSessionDeletionRefs { attachmentBinaryDataIds: string[]; executionLogs: Array>; @@ -404,6 +425,125 @@ export class AgentExecutionThreadRepository extends BaseRepository { + return await this.find({ + where: { agentId, ownerId, accessScope: 'user', parentThreadId: IsNull() }, + order: { updatedAt: 'DESC', id: 'DESC' }, + ...(options.limit ? { take: options.limit } : {}), + }); + } + + /** One top-level private session of a user with an agent. */ + async findOwnedById( + agentId: string, + ownerId: string, + threadId: string, + ): Promise { + return await this.findOneBy(ownedSessionWhere(agentId, ownerId, threadId)); + } + + /** + * Rename a top-level private session of a user with an agent. Returns false + * when no such session exists. + */ + async renameOwned( + agentId: string, + ownerId: string, + threadId: string, + title: string, + ctx: OperationContext, + ): Promise { + const result = await this.managerFor(ctx).update( + AgentExecutionThread, + ownedSessionWhere(agentId, ownerId, threadId), + { title }, + ); + return result.affected === 1; + } + + /** + * Move a top-level private session of a user with an agent to another + * project. Returns false when no such session exists. The caller must check + * that the user can use the target project. + */ + async moveOwned( + agentId: string, + ownerId: string, + threadId: string, + projectId: string, + ctx: OperationContext, + ): Promise { + const result = await this.managerFor(ctx).update( + AgentExecutionThread, + ownedSessionWhere(agentId, ownerId, threadId), + { projectId }, + ); + return result.affected === 1; + } + + /** + * One page of the top-level private sessions of a user with an agent, + * newest first. `before` is the last session of the previous page. The + * `id` breaks ties between sessions with the same `updatedAt`, so no + * session is skipped or repeated across pages. + */ + async findOwnedHistoryPage( + agentId: string, + ownerId: string, + limit: number, + search?: string, + before?: AgentExecutionThreadKeyset, + ): Promise { + const updatedAt = this.updatedAtExpression(); + const query = this.createQueryBuilder('thread') + .where('thread.agentId = :agentId', { agentId }) + .andWhere('thread.ownerId = :ownerId', { ownerId }) + .andWhere("thread.accessScope = 'user'") + .andWhere('thread.parentThreadId IS NULL'); + const term = search?.trim().toLowerCase(); + if (term) { + query.andWhere(`LOWER(thread.title) LIKE :search ${LIKE_ESCAPE_CLAUSE}`, { + search: `%${escapeLike(term)}%`, + }); + } + if (before) { + query.andWhere( + `(${updatedAt} < :beforeAt OR (${updatedAt} = :beforeAt AND thread.id < :beforeId))`, + { beforeAt: before.updatedAt, beforeId: before.id }, + ); + } + // Order by the same expression as the keyset, so ties stored in another format stay together. + const threads = await query + .orderBy(updatedAt, 'DESC') + .addOrderBy('thread.id', 'DESC') + .limit(limit + 1) + .getMany(); + const hasMore = threads.length > limit; + if (hasMore) threads.pop(); + return { threads, hasMore }; + } + + /** Sessions of an agent last updated before `cutoff`, oldest first, for pruning. */ + async findByAgentUpdatedBefore( + agentId: string, + cutoff: Date, + limit: number, + ): Promise { + const updatedAt = this.updatedAtExpression(); + return await this.createQueryBuilder('thread') + .where('thread.agentId = :agentId', { agentId }) + .andWhere(`${updatedAt} < :cutoff`, { cutoff }) + .orderBy(updatedAt, 'ASC') + .addOrderBy('thread.id', 'ASC') + .limit(limit) + .getMany(); + } + async findByParentThreadId( parentThreadId: string, projectId: string, diff --git a/packages/cli/src/modules/agents/repositories/agent-message-queue.repository.ts b/packages/cli/src/modules/agents/repositories/agent-message-queue.repository.ts index bf35dd3f6c7..8f164083802 100644 --- a/packages/cli/src/modules/agents/repositories/agent-message-queue.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-message-queue.repository.ts @@ -42,6 +42,15 @@ export class AgentMessageQueueRepository extends BaseRepository