mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
feat(core): Scope agent sessions to projects (#36646)
This commit is contained in:
@@ -1340,6 +1340,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
const MESSAGE = 'hello';
|
||||
const EXEC_ID = 'exec-id';
|
||||
const THREAD_ID = 'thread-id';
|
||||
const PROJECT_THREAD_ID = `workflow:project-project-1:${THREAD_ID}`;
|
||||
const executionSandboxScope = {
|
||||
principalHash: hashAgentSandboxPrincipal({
|
||||
type: 'workflow-execution',
|
||||
@@ -1347,13 +1348,21 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
executionId: EXEC_ID,
|
||||
}),
|
||||
};
|
||||
const sessionSandboxScope = {
|
||||
const projectSessionSandboxScope = {
|
||||
principalHash: hashAgentSandboxPrincipal({
|
||||
type: 'workflow-session',
|
||||
workflowId: 'workflow-1',
|
||||
type: 'project-session',
|
||||
projectId: 'project-1',
|
||||
sessionId: THREAD_ID,
|
||||
}),
|
||||
};
|
||||
const callerWorkflowContext = (workflowId: string): ExecuteAgentWorkflowContext => ({
|
||||
workflowId,
|
||||
workflowName: 'My workflow',
|
||||
callingNodeName: 'Message an Agent',
|
||||
hasCallerSessionId: true,
|
||||
nodes: [{ name: 'Webhook', type: 'n8n-nodes-base.webhook' }],
|
||||
runExecutionData: { resultData: { runData: {} } } as unknown as IRunExecutionData,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -1382,7 +1391,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
inlineAgent,
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
`wf:workflow-1:${THREAD_ID}`,
|
||||
PROJECT_THREAD_ID,
|
||||
'project-1',
|
||||
'user-1',
|
||||
'production',
|
||||
@@ -1407,7 +1416,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
inlineAgent,
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
`wf:workflow-1:${THREAD_ID}`,
|
||||
PROJECT_THREAD_ID,
|
||||
'project-1',
|
||||
'user-1',
|
||||
'test',
|
||||
@@ -1437,7 +1446,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
AGENT_ID,
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
`wf:workflow-1:${THREAD_ID}`,
|
||||
PROJECT_THREAD_ID,
|
||||
'project-1',
|
||||
'user-1',
|
||||
true,
|
||||
@@ -1473,7 +1482,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
AGENT_ID,
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
`wf:workflow-1:${THREAD_ID}`,
|
||||
PROJECT_THREAD_ID,
|
||||
'project-1',
|
||||
'user-1',
|
||||
true,
|
||||
@@ -1513,16 +1522,128 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
AGENT_ID,
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
`wf:workflow-1:${THREAD_ID}`,
|
||||
PROJECT_THREAD_ID,
|
||||
'project-1',
|
||||
'user-1',
|
||||
true,
|
||||
undefined,
|
||||
workflowContext,
|
||||
sessionSandboxScope,
|
||||
projectSessionSandboxScope,
|
||||
);
|
||||
});
|
||||
|
||||
it('shares a caller session across workflows in the same project', async () => {
|
||||
const firstAdditionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
workflowId: 'workflow-1',
|
||||
});
|
||||
const secondAdditionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
workflowId: 'workflow-2',
|
||||
});
|
||||
|
||||
await executeAgent(
|
||||
{ agentId: AGENT_ID },
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
THREAD_ID,
|
||||
firstAdditionalData,
|
||||
'manual',
|
||||
undefined,
|
||||
callerWorkflowContext('workflow-1'),
|
||||
);
|
||||
await executeAgent(
|
||||
{ agentId: AGENT_ID },
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
THREAD_ID,
|
||||
secondAdditionalData,
|
||||
'manual',
|
||||
undefined,
|
||||
callerWorkflowContext('workflow-2'),
|
||||
);
|
||||
|
||||
expect(
|
||||
agentWorkflowExecutionService.executeForWorkflow.mock.calls.map((call) => call[3]),
|
||||
).toEqual([PROJECT_THREAD_ID, PROJECT_THREAD_ID]);
|
||||
});
|
||||
|
||||
it('isolates the same caller session ID between projects', async () => {
|
||||
const firstAdditionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
workflowId: 'workflow-1',
|
||||
});
|
||||
const secondAdditionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
userId: 'user-1',
|
||||
projectId: 'project-2',
|
||||
workflowId: 'workflow-2',
|
||||
});
|
||||
|
||||
await executeAgent(
|
||||
{ agentId: AGENT_ID },
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
THREAD_ID,
|
||||
firstAdditionalData,
|
||||
'manual',
|
||||
undefined,
|
||||
callerWorkflowContext('workflow-1'),
|
||||
);
|
||||
await executeAgent(
|
||||
{ agentId: AGENT_ID },
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
THREAD_ID,
|
||||
secondAdditionalData,
|
||||
'manual',
|
||||
undefined,
|
||||
callerWorkflowContext('workflow-2'),
|
||||
);
|
||||
|
||||
expect(
|
||||
agentWorkflowExecutionService.executeForWorkflow.mock.calls.map((call) => call[3]),
|
||||
).toEqual([PROJECT_THREAD_ID, `workflow:project-project-2:${THREAD_ID}`]);
|
||||
});
|
||||
|
||||
it('keeps the caller-facing session ID unchanged', async () => {
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
workflowId: 'workflow-1',
|
||||
});
|
||||
agentWorkflowExecutionService.executeForWorkflow.mockResolvedValueOnce(
|
||||
mock<Awaited<ReturnType<typeof agentWorkflowExecutionService.executeForWorkflow>>>({
|
||||
session: {
|
||||
agentId: AGENT_ID,
|
||||
projectId: 'project-1',
|
||||
sessionId: PROJECT_THREAD_ID,
|
||||
threadId: PROJECT_THREAD_ID,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executeAgent(
|
||||
{ agentId: AGENT_ID },
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
THREAD_ID,
|
||||
additionalData,
|
||||
'manual',
|
||||
undefined,
|
||||
callerWorkflowContext('workflow-1'),
|
||||
);
|
||||
|
||||
expect(result.session).toEqual({
|
||||
agentId: AGENT_ID,
|
||||
projectId: 'project-1',
|
||||
sessionId: THREAD_ID,
|
||||
threadId: PROJECT_THREAD_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('backfills projectId from the workflow owner when missing', async () => {
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
userId: 'user-1',
|
||||
@@ -1547,7 +1668,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
AGENT_ID,
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
`wf:workflow-1:${THREAD_ID}`,
|
||||
PROJECT_THREAD_ID,
|
||||
'project-1',
|
||||
'user-1',
|
||||
true,
|
||||
@@ -1610,7 +1731,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
AGENT_ID,
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
`wf:workflow-1:${THREAD_ID}`,
|
||||
PROJECT_THREAD_ID,
|
||||
'project-1',
|
||||
undefined,
|
||||
false,
|
||||
@@ -1643,7 +1764,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
AGENT_ID,
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
`wf:workflow-1:${THREAD_ID}`,
|
||||
PROJECT_THREAD_ID,
|
||||
'project-1',
|
||||
'user-1',
|
||||
true,
|
||||
@@ -1678,7 +1799,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
AGENT_ID,
|
||||
MESSAGE,
|
||||
EXEC_ID,
|
||||
`wf:workflow-1:${THREAD_ID}`,
|
||||
PROJECT_THREAD_ID,
|
||||
'project-1',
|
||||
'user-1',
|
||||
false,
|
||||
|
||||
@@ -14,8 +14,8 @@ describe('hashAgentSandboxPrincipal', () => {
|
||||
'7Zqe0BHA0mDnH7Ci9p-Zy7W2uVPQhf_4h01KkKpHnlU',
|
||||
],
|
||||
[
|
||||
{ type: 'workflow-session', workflowId: 'workflow|one', sessionId: 'session|two' },
|
||||
'U1yUIOzcWHMbn3uOWB_UKJCbW1yZ_aqsOZJl_o3omc0',
|
||||
{ type: 'project-session', projectId: 'project|one', sessionId: 'session|two' },
|
||||
'hvv8BfUEg9z51q2osaDXES2zFyThZImKNiT2euVvAUA',
|
||||
],
|
||||
[
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ export type AgentSandboxPrincipal =
|
||||
platform: string;
|
||||
platformUserId: string;
|
||||
}
|
||||
| { type: 'workflow-session'; workflowId: string; sessionId: string }
|
||||
| { type: 'project-session'; projectId: string; sessionId: string }
|
||||
| {
|
||||
type: 'workflow-execution';
|
||||
workflowId: string;
|
||||
@@ -84,8 +84,8 @@ export function hashAgentSandboxPrincipal(
|
||||
principal.platformUserId,
|
||||
];
|
||||
break;
|
||||
case 'workflow-session':
|
||||
canonicalPrincipal = [principal.type, principal.workflowId, principal.sessionId];
|
||||
case 'project-session':
|
||||
canonicalPrincipal = [principal.type, principal.projectId, principal.sessionId];
|
||||
break;
|
||||
case 'workflow-execution':
|
||||
canonicalPrincipal = [principal.type, principal.workflowId, principal.executionId];
|
||||
|
||||
@@ -399,8 +399,7 @@ export async function executeAgent(
|
||||
throw new UnexpectedError('Cannot execute agent without a workflowId in additional data');
|
||||
}
|
||||
|
||||
// Scope session threads by workflow
|
||||
const scopedThreadId = `wf:${additionalData.workflowId}:${threadId}`;
|
||||
const scopedThreadId = `workflow:project-${projectId}:${threadId}`;
|
||||
|
||||
if (source.inlineAgent) {
|
||||
return await agentWorkflowExecutionService.executeInlineForWorkflow(
|
||||
@@ -422,8 +421,8 @@ export async function executeAgent(
|
||||
workflowContext?.hasCallerSessionId === true
|
||||
? {
|
||||
principalHash: hashAgentSandboxPrincipal({
|
||||
type: 'workflow-session',
|
||||
workflowId: additionalData.workflowId,
|
||||
type: 'project-session',
|
||||
projectId,
|
||||
sessionId: threadId,
|
||||
}),
|
||||
}
|
||||
|
||||
+8
-6
@@ -27,6 +27,8 @@ import { describeCommonTests } from './shared-tests';
|
||||
import { ExecuteContext } from '../execute-context';
|
||||
import * as validateUtil from '../utils/validate-value-against-schema';
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
describe('ExecuteContext', () => {
|
||||
const testCredentialType = 'testCredential';
|
||||
const nodeType = mock<INodeType>({
|
||||
@@ -464,7 +466,7 @@ describe('ExecuteContext', () => {
|
||||
{ agentId: 'agent-1' },
|
||||
'hello',
|
||||
'exec-1',
|
||||
'exec-1-0',
|
||||
expect.stringMatching(UUID_PATTERN),
|
||||
agentAdditionalData,
|
||||
'manual',
|
||||
undefined,
|
||||
@@ -502,7 +504,7 @@ describe('ExecuteContext', () => {
|
||||
{ agentId: 'agent-1' },
|
||||
'hello',
|
||||
'exec-1',
|
||||
'exec-1-0',
|
||||
expect.stringMatching(UUID_PATTERN),
|
||||
agentAdditionalData,
|
||||
'manual',
|
||||
undefined,
|
||||
@@ -550,7 +552,7 @@ describe('ExecuteContext', () => {
|
||||
{ agentId: 'agent-1' },
|
||||
'hello',
|
||||
'exec-1',
|
||||
'exec-1-1',
|
||||
expect.stringMatching(UUID_PATTERN),
|
||||
twoItemAdditionalData,
|
||||
'manual',
|
||||
undefined,
|
||||
@@ -594,7 +596,7 @@ describe('ExecuteContext', () => {
|
||||
{ agentId: 'agent-1' },
|
||||
'hello',
|
||||
'exec-1',
|
||||
'exec-1-0',
|
||||
expect.stringMatching(UUID_PATTERN),
|
||||
twoItemAdditionalData,
|
||||
'manual',
|
||||
undefined,
|
||||
@@ -641,7 +643,7 @@ describe('ExecuteContext', () => {
|
||||
{ agentId: 'agent-1' },
|
||||
'hello',
|
||||
'exec-1',
|
||||
'exec-1-0',
|
||||
expect.stringMatching(UUID_PATTERN),
|
||||
multiBranchAdditionalData,
|
||||
'manual',
|
||||
undefined,
|
||||
@@ -688,7 +690,7 @@ describe('ExecuteContext', () => {
|
||||
{ agentId: 'agent-1' },
|
||||
'hello',
|
||||
'exec-1',
|
||||
'exec-1-5',
|
||||
expect.stringMatching(UUID_PATTERN),
|
||||
outOfRangeAdditionalData,
|
||||
'manual',
|
||||
undefined,
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
shouldRedactConsoleOutput,
|
||||
CONSOLE_OUTPUT_REDACTED_MESSAGE,
|
||||
} from 'n8n-workflow';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/constants';
|
||||
import { deepMerge } from '@/utils/deep-merge';
|
||||
@@ -212,7 +213,7 @@ export class BaseExecuteContext extends NodeExecutionContext {
|
||||
}
|
||||
|
||||
const callerSessionId = agentInfo.sessionId?.trim();
|
||||
const threadId = callerSessionId || `${executionId}-${itemIndex}`;
|
||||
const threadId = callerSessionId || randomUUID();
|
||||
|
||||
const inputDataScope = agentInfo.inputDataScope ?? 'item';
|
||||
const mainBranches = this.inputData?.main ?? [];
|
||||
|
||||
Reference in New Issue
Block a user