From e7165c662ad36107f72f51f75da8cad62962bdb4 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Thu, 15 Oct 2026 14:35:04 +0300 Subject: [PATCH] feat(core): Add instance-scoped agents to the Agents schema (no-changelog) Map the agents.scope column on the Agent entity. An instance agent is code-defined and belongs to no project. AgentRepository.ensureInstanceAgent creates its row with an insert that ignores a concurrent duplicate. Type Agent.projectId as string | null. Project-scoped repository queries and writes filter on scope = 'project'. The queries return ProjectAgent, which has a non-null projectId. The project-agent APIs do not see instance agents and report them as not found. Co-Authored-By: Claude Opus 5.5 --- .../collaboration.service.agent.test.ts | 4 +- ...nt-eval-runner.service.integration.test.ts | 8 +- .../agent-eval-runner.service.test.ts | 4 +- .../__tests__/agent-eval.service.test.ts | 4 +- .../__tests__/agent-config.service.test.ts | 40 ++-- .../agent-custom-tools.service.test.ts | 6 +- ...ent-execution-orchestrator.service.test.ts | 14 +- ...ent-integration-management.service.test.ts | 8 +- ...nt-integration-persistence.service.test.ts | 4 +- .../agent-integrations.controller.test.ts | 14 +- .../__tests__/agent-publish.service.test.ts | 6 +- .../agent-runtime-cache.service.test.ts | 6 +- ...time-reconstruction-tool-filtering.test.ts | 6 +- .../agent-sandbox-runtime.service.test.ts | 4 +- .../__tests__/agent-skills.service.test.ts | 8 +- .../agents/__tests__/agent-telemetry.test.ts | 9 +- .../agent-validation.service.test.ts | 8 +- ...ent-workflow-dependency.repository.test.ts | 20 ++ .../agent-workflow-execution.service.test.ts | 12 +- .../agents/__tests__/agent.repository.test.ts | 32 ++- .../agents-builder-tools.service.test.ts | 19 +- .../agents-service-reconstruct-gating.test.ts | 20 +- .../agents/__tests__/agents.service.test.ts | 10 +- .../instance-ai-agent-context.adapter.test.ts | 6 +- ...stance-ai-builder-delegate.adapter.test.ts | 12 +- .../test-utils/stored-agent-config.ts | 6 +- .../modules/agents/agent-config.service.ts | 20 +- .../modules/agents/agent-execution.service.ts | 5 + .../agent-integration-management.service.ts | 32 +-- .../agent-integration-persistence.service.ts | 12 +- .../agents/agent-mcp-access.service.ts | 4 +- .../modules/agents/agent-publish.service.ts | 32 +-- .../src/modules/agents/agent-task.service.ts | 28 +-- .../cli/src/modules/agents/agents.service.ts | 50 +++-- .../agents/background/agent-wake.service.ts | 4 +- .../agents-builder.service.session.test.ts | 4 +- .../modules/agents/entities/agent.entity.ts | 38 +++- .../agent-channel-reconciler.service.test.ts | 6 +- .../chat-integration.service.test.ts | 6 +- .../__tests__/discord-integration.test.ts | 4 +- .../__tests__/slack-methods.service.test.ts | 4 +- .../__tests__/teams-integration.test.ts | 4 +- .../agent-channel-reconciler.service.ts | 10 +- .../integrations/chat-integration.service.ts | 13 +- .../agents/integrations/integrations-sync.ts | 4 +- .../__tests__/telegram/integration.test.ts | 4 +- .../slack/slack-managed-setup.service.ts | 18 +- .../platforms/slack/slack-methods.service.ts | 6 +- .../__tests__/teams-setup.service.test.ts | 6 +- .../platforms/teams/teams-setup.service.ts | 10 +- .../agents/register-favorite-resolver.ts | 3 +- .../agent-workflow-dependency.repository.ts | 5 +- .../agents/repositories/agent.repository.ts | 208 ++++++++++++------ .../agents/repositories/skill.repository.ts | 16 +- .../modules/agents/skills/skill.service.ts | 4 +- .../sub-agent-cleanup.service.test.ts | 6 +- .../sub-agent-source-resolver.test.ts | 6 +- .../agents/utils/agent-published-snapshot.ts | 16 +- .../agents/utils/get-agent-or-throw.ts | 4 +- .../instance-ai-pending-agent.service.test.ts | 24 +- .../mcp/__tests__/agent-tools.service.test.ts | 8 +- .../mcp/tools/agents/agent-slack-setup.ts | 12 +- .../mcp/tools/agents/agent-tools.service.ts | 24 +- .../agent/__tests__/agent.exporter.test.ts | 8 + .../entities/agent/agent.exporter.ts | 8 +- .../entities/agent/agent.serializer.ts | 1 + .../agents/agent-conversation-state.test.ts | 2 +- .../agents/agent-skills-hub.test.ts | 14 +- .../agent-background-job.repository.test.ts | 8 +- .../agent-instance-scope.repository.test.ts | 177 +++++++++++++++ 70 files changed, 775 insertions(+), 393 deletions(-) create mode 100644 packages/cli/test/integration/database/repositories/agent-instance-scope.repository.test.ts diff --git a/packages/cli/src/collaboration/__tests__/collaboration.service.agent.test.ts b/packages/cli/src/collaboration/__tests__/collaboration.service.agent.test.ts index 3c16fc773f3..406d17f9b9e 100644 --- a/packages/cli/src/collaboration/__tests__/collaboration.service.agent.test.ts +++ b/packages/cli/src/collaboration/__tests__/collaboration.service.agent.test.ts @@ -3,7 +3,7 @@ import { mock } from 'vitest-mock-extended'; import type { User } from '@n8n/db'; import type { AgentRepository } from '@/modules/agents/repositories/agent.repository'; -import type { Agent } from '@/modules/agents/entities/agent.entity'; +import type { ProjectAgent } from '@/modules/agents/entities/agent.entity'; import { CollaborationService } from '../collaboration.service'; import type { @@ -46,7 +46,7 @@ describe('CollaborationService — agent messages', () => { toIUser: () => ({ id: 'user-1' }), } as unknown as User); - agentRepository.findById.mockResolvedValue({ projectId: 'project-1' } as Agent); + agentRepository.findById.mockResolvedValue({ projectId: 'project-1' } as ProjectAgent); agentRepository.getProjectIdById.mockResolvedValue('project-1'); agentRepository.existsByIdAndProjectId.mockResolvedValue(true); diff --git a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.integration.test.ts b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.integration.test.ts index 2f999f202ed..645649fa4fc 100644 --- a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.integration.test.ts +++ b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.integration.test.ts @@ -18,7 +18,7 @@ import { CredentialsService } from '@/credentials/credentials.service'; import { License } from '@/license'; import { AgentConfigService } from '@/modules/agents/agent-config.service'; import { AgentsSettingsService } from '@/modules/agents/agents-settings.service'; -import { Agent } from '@/modules/agents/entities/agent.entity'; +import { Agent, type ProjectAgent } from '@/modules/agents/entities/agent.entity'; import type { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import { DataTableService } from '@/modules/data-table/data-table.service'; import type { EvalAgentExecutionService } from '@/modules/instance-ai/eval/agent-execution.service'; @@ -77,7 +77,7 @@ const buildRunner = () => ); /** Insert a minimal real agent row so `agent_eval_dataset.agentId`'s FK holds. */ -async function createAgent(projectId: string): Promise { +async function createAgent(projectId: string): Promise { return await Container.get(DataSource) .getRepository(Agent) .save(Object.assign(new Agent(), { name: 'test agent', projectId })); @@ -137,7 +137,7 @@ describe('AgentEvalRunnerService (integration)', () => { }); agentRepository.findByIdAndProjectId.mockResolvedValue( - mock({ id: agent.id, activeVersionId: null }), + mock({ id: agent.id, activeVersionId: null }), ); evalAgentExecutionService.executeWithLlmMock.mockImplementation( async (_agentId, _user, _options, caseInput) => @@ -206,7 +206,7 @@ describe('AgentEvalRunnerService (integration)', () => { }); agentRepository.findByIdAndProjectId.mockResolvedValue( - mock({ id: agent.id, activeVersionId: null }), + mock({ id: agent.id, activeVersionId: null }), ); evalAgentExecutionService.executeWithLlmMock.mockResolvedValue({ runId: '', diff --git a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.test.ts b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.test.ts index 5548f5d9760..8b02be3d9df 100644 --- a/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.test.ts +++ b/packages/cli/src/modules/agent-evals/__tests__/agent-eval-runner.service.test.ts @@ -18,7 +18,7 @@ import { resolveEvaluationConcurrencyLimit } from '@/evaluation.ee/evaluation-co import type { License } from '@/license'; import type { AgentConfigService } from '@/modules/agents/agent-config.service'; import type { AgentsSettingsService } from '@/modules/agents/agents-settings.service'; -import type { Agent } from '@/modules/agents/entities/agent.entity'; +import type { ProjectAgent } from '@/modules/agents/entities/agent.entity'; import type { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import type { DataTableService } from '@/modules/data-table/data-table.service'; import type { EvalAgentExecutionService } from '@/modules/instance-ai/eval/agent-execution.service'; @@ -192,7 +192,7 @@ describe('AgentEvalRunnerService', () => { datasetRepository.findById.mockResolvedValue(dataset); agentRepository.findByIdAndProjectId.mockResolvedValue( - mock({ id: 'agent-1', activeVersionId: 'v-1' }), + mock({ id: 'agent-1', activeVersionId: 'v-1' }), ); runRepository.createRun.mockResolvedValue(mock({ id: 'run-1' })); runRepository.isCancellationRequested.mockResolvedValue(false); diff --git a/packages/cli/src/modules/agent-evals/__tests__/agent-eval.service.test.ts b/packages/cli/src/modules/agent-evals/__tests__/agent-eval.service.test.ts index c83945583c5..9bc993ac05c 100644 --- a/packages/cli/src/modules/agent-evals/__tests__/agent-eval.service.test.ts +++ b/packages/cli/src/modules/agent-evals/__tests__/agent-eval.service.test.ts @@ -14,7 +14,7 @@ import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '@ import type { CredentialsService } from '@/credentials/credentials.service'; import type { AgentConfigService } from '@/modules/agents/agent-config.service'; import { userHasScopes } from '@/permissions.ee/check-access'; -import type { Agent } from '@/modules/agents/entities/agent.entity'; +import type { ProjectAgent } from '@/modules/agents/entities/agent.entity'; import type { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import type { AgentEvalCaseGenerationService } from '../agent-eval-case-generation.service'; @@ -130,7 +130,7 @@ describe('AgentEvalService', () => { caseGenerationService = mock(); agentConfigService = mock(); - agentRepository.findByIdAndProjectId.mockResolvedValue(mock({ id: AGENT_ID })); + agentRepository.findByIdAndProjectId.mockResolvedValue(mock({ id: AGENT_ID })); datasetRepository.findByIdAndAgentId.mockResolvedValue(makeDataset()); runRepository.findByIdAndAgentId.mockResolvedValue(makeRun()); runRepository.findAndCountByDatasetIdAndAgentId.mockResolvedValue([[], 0]); diff --git a/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts index 929a472aabd..f218b180e46 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts @@ -23,7 +23,7 @@ import { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import { AgentSetupCompletionService } from '../agent-setup-completion.service'; import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; import type { AgentValidationService } from '../agent-validation.service'; -import type { Agent } from '../entities/agent.entity'; +import type { Agent, ProjectAgent } from '../entities/agent.entity'; import { syncAgentIntegrations } from '../integrations/integrations-sync'; import type { NodeToolAiGatewayService } from '../json-config/node-tool-ai-gateway.service'; import type { AgentTaskRepository } from '../repositories/agent-task.repository'; @@ -52,7 +52,7 @@ const storedCustomTool = { tool_1: { code: 'a', descriptor: { name: 'tool_1', description: 'a', inputSchema: {} } }, } as unknown as Agent['tools']; -function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { +function makeAgent(overrides: AgentFixtureOverrides = {}): ProjectAgent { return { id: agentId, name: 'Support Agent', @@ -65,11 +65,11 @@ function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { setupCompletedAt: null, updatedAt: new Date('2025-01-01T00:00:00Z'), ...overrides, - } as unknown as Agent; + } as unknown as ProjectAgent; } /** Write options fenced against `agent`, the entity the repository mock returns. */ -function fencedOn(agent: Agent) { +function fencedOn(agent: ProjectAgent) { return { modifiedBy: 'user', baseConfigHash: getAgentConfigHash(composeStoredJsonConfig(agent)), @@ -414,7 +414,7 @@ describe('AgentConfigService', () => { fencedOn(agent), ); - const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect(saved.schema?.config?.webSearch).toEqual({ enabled: false }); expect(saved.schema?.providerTools).toEqual({}); // The returned (composed) config reflects the persisted state so the tool @@ -471,7 +471,7 @@ describe('AgentConfigService', () => { user, fencedOn(agent), ); - let saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + let saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect(saved.schema).toEqual( expect.objectContaining({ instructions: 'Updated instructions', @@ -490,7 +490,7 @@ describe('AgentConfigService', () => { user, fencedOn(agent), ); - saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect(saved.integrations).toEqual([]); expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); }); @@ -503,7 +503,7 @@ describe('AgentConfigService', () => { await service.updateConfig(agentId, projectId, { ...baseConfig }, user, fencedOn(agent)); - const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect(saved.integrations).toEqual([slack]); expect(syncAgentIntegrations).not.toHaveBeenCalled(); }); @@ -526,7 +526,7 @@ describe('AgentConfigService', () => { { clearOmittedOptionalFields: true, ...fencedOn(agent) }, ); - const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect(saved.schema?.instructions).toBe('Reached through an entrypoint workflow'); expect(saved.integrations).toEqual([]); expect(syncAgentIntegrations).toHaveBeenCalledWith(saved, [telegram], [], expect.anything()); @@ -561,7 +561,7 @@ describe('AgentConfigService', () => { agentRepository.findByIdAndProjectId.mockResolvedValue(agent); const save = async (config: AgentJsonConfig) => { await service.updateConfig(agentId, projectId, config, user, fencedOn(agent)); - return agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + return agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; }; let saved = await save({ ...baseConfig, description: ' Answers support tickets ' }); @@ -586,12 +586,12 @@ describe('AgentConfigService', () => { user, fencedOn(agent), ); - let saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + let saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect(saved.schema?.modelDeploymentName).toBe('my-gpt4o-deployment'); // Omitting the field keeps the stored value (merge semantics). await service.updateConfig(agentId, projectId, { ...baseConfig }, user, fencedOn(agent)); - saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect(saved.schema?.modelDeploymentName).toBe('my-gpt4o-deployment'); // An explicit empty value is a clear (the builder sends "" when the @@ -603,7 +603,7 @@ describe('AgentConfigService', () => { user, fencedOn(agent), ); - saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect(saved.schema).not.toHaveProperty('modelDeploymentName'); await service.updateConfig( @@ -619,7 +619,7 @@ describe('AgentConfigService', () => { clearOmittedOptionalFields: true, ...fencedOn(agent), }); - saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect(saved.schema).not.toHaveProperty('modelDeploymentName'); }); @@ -644,7 +644,7 @@ describe('AgentConfigService', () => { { clearOmittedOptionalFields: true, ...fencedOn(agent) }, ); - const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; // Provided fields keep their submitted value; omitted ones are removed // instead of retaining the stored value. expect(saved.schema?.memory).toEqual({ enabled: false, storage: 'n8n' }); @@ -662,7 +662,7 @@ describe('AgentConfigService', () => { await service.updateConfig(agentId, projectId, { ...baseConfig }, user, fencedOn(agent)); - const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect(saved.schema).toHaveProperty('description', ''); }); @@ -685,7 +685,7 @@ describe('AgentConfigService', () => { ...fencedOn(agent), }); - const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect(saved.tools).toEqual({}); expect(skillRefs.replaceDraftRefs).toHaveBeenCalledWith(saved, undefined, {}); expect(agentTaskRepository.deleteForAgent).toHaveBeenCalledWith(agentId, ['task-1'], {}); @@ -705,7 +705,7 @@ describe('AgentConfigService', () => { await service.updateConfig(agentId, projectId, { ...baseConfig }, user, fencedOn(agent)); - const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect(saved.tools).toEqual(storedCustomTool); expect(skillRefs.replaceDraftRefs).toHaveBeenCalledWith( saved, @@ -734,7 +734,7 @@ describe('AgentConfigService', () => { excludePersonalRoute: true, }); expect(credentialsService.findAllCredentialIdsForProject).not.toHaveBeenCalled(); - const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect((saved.schema as AgentJsonConfig).credential).toBe('user-cred'); }); @@ -779,7 +779,7 @@ describe('AgentConfigService', () => { ['wf-id-1', 'Existing Name', 'ghost'], ['wf-id-1', 'Existing Name', 'ghost'], ); - const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; + const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as ProjectAgent; expect(saved.schema?.tools).toEqual([ { type: 'workflow', diff --git a/packages/cli/src/modules/agents/__tests__/agent-custom-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-custom-tools.service.test.ts index 971bd680c06..aa8228d826d 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-custom-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-custom-tools.service.test.ts @@ -12,7 +12,7 @@ import { AgentSaveCompletionService } from '../agent-save-completion.service'; import { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import { AgentCustomToolsService } from '../agent-custom-tools.service'; import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import type { AgentRepository } from '../repositories/agent.repository'; import { type AgentFixtureOverrides, @@ -35,7 +35,7 @@ const descriptor: ToolDescriptor = { providerOptions: null, }; -function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { +function makeAgent(overrides: AgentFixtureOverrides = {}): ProjectAgent { return { id: agentId, projectId, @@ -52,7 +52,7 @@ function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { skills: {}, updatedAt: new Date('2025-01-01T00:00:00Z'), ...overrides, - } as unknown as Agent; + } as unknown as ProjectAgent; } function makeService() { 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 d986ae8b7f8..9c9564e04de 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 @@ -54,7 +54,7 @@ import type { AgentToolApprovalService } from '../agent-tool-approval.service'; import type { AgentValidationService } from '../agent-validation.service'; import { AgentBudgetAlertService } from '../agent-budget-alert.service'; import { AgentSpendLedger } from '../budget-guardrail'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import type { AgentBackgroundJob } from '../entities/agent-background-job.entity'; import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity'; import type { AgentRepository } from '../repositories/agent.repository'; @@ -823,7 +823,7 @@ describe('AgentExecutionOrchestratorService', () => { schema, activeVersion: { schema }, integrations: [], - } as unknown as Agent); + } as unknown as ProjectAgent); settingsService.assertEnabled.mockRejectedValue(new UserError('Agents are disabled')); const input = { agentId, @@ -2314,7 +2314,7 @@ describe('AgentExecutionOrchestratorService', () => { name: 'Support Agent', schema, integrations: [], - } as unknown as Agent); + } as unknown as ProjectAgent); if (failure !== 'none') executionService[failure].mockRejectedValue(cause); const onExecutionRecorded = vi.fn(); const result = collect( @@ -2349,7 +2349,7 @@ describe('AgentExecutionOrchestratorService', () => { it('does not record a runtime failure when cancellation arrives during agent lookup', async () => { const { service, runtimeCacheService, executionService, agentRepository } = makeService(); const controller = new AbortController(); - const agent = createDeferredPromise(); + const agent = createDeferredPromise(); runtimeCacheService.getRuntime.mockRejectedValue(new Error('runtime initialization failed')); agentRepository.findByIdAndProjectId.mockReturnValue(agent.promise); @@ -2461,7 +2461,7 @@ describe('AgentExecutionOrchestratorService', () => { const { service, runtimeCacheService, executionService, agentRepository } = makeService(); const buildError = new UserError('Credential "OpenAI" not found'); runtimeCacheService.getRuntime.mockRejectedValue(buildError); - // A plain object: `mock()` proxies nested fields, which breaks the + // A plain object: `mock()` proxies nested fields, which breaks the // telemetry builder's array handling of `schema`. agentRepository.findByIdAndProjectId.mockResolvedValue({ id: agentId, @@ -2469,7 +2469,7 @@ describe('AgentExecutionOrchestratorService', () => { schema: { ...schema, name: 'Support Agent (draft)' }, activeVersion: { schema }, integrations: [], - } as unknown as Agent); + } as unknown as ProjectAgent); await expect( collect( @@ -2521,7 +2521,7 @@ describe('AgentExecutionOrchestratorService', () => { schema, activeVersion: { schema }, integrations: [], - } as unknown as Agent); + } as unknown as ProjectAgent); await expect( collect( diff --git a/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts index b531394a42b..c27a9464a3a 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts @@ -10,7 +10,7 @@ import { BadRequestError, NotFoundError } from '@n8n/errors'; import { AgentIntegrationManagementService } from '../agent-integration-management.service'; import type { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service'; import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import type { AgentChatIntegration, ChatIntegrationRegistry, @@ -26,14 +26,14 @@ describe('AgentIntegrationManagementService', () => { } satisfies AgentIntegrationConfig; const replaced = { type: 'slack', credentialId: 'credential-0' } satisfies AgentIntegrationConfig; - function makeAgent(overrides: Partial = {}): Agent { + function makeAgent(overrides: Partial = {}): ProjectAgent { return { id: 'agent-1', projectId: 'project-1', activeVersionId: 'version-1', integrations: [], ...overrides, - } as Agent; + } as ProjectAgent; } function makeService() { @@ -81,7 +81,7 @@ describe('AgentIntegrationManagementService', () => { * Mirrors what the real delta does to the entity: it corrects `activeVersionId` * to the row it read, which is what callers derive their response from. */ - function deltaResult(agent: Agent, published: boolean, extra: object = {}) { + function deltaResult(agent: ProjectAgent, published: boolean, extra: object = {}) { agent.activeVersionId = published ? 'version-1' : null; return { agent, changed: true, published, ...extra }; } diff --git a/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts index 24b3a8c9c3c..6ba7fb0999c 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts @@ -13,7 +13,7 @@ import { AgentIntegrationPersistenceService } from '../agent-integration-persist import { AgentModificationTelemetryService } from '../agent-modification-telemetry.service'; import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import type { AgentSetupCompletionService } from '../agent-setup-completion.service'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import type { ChatIntegrationRegistry } from '../integrations/agent-chat-integration'; import type { AgentRepository } from '../repositories/agent.repository'; import { createAgentSkillRefsService, storedAgentConfig } from './test-utils/stored-agent-config'; @@ -69,7 +69,7 @@ function setup(options: SetupOptions = {}) { activeVersionId: options.activeVersionId === undefined ? 'version-1' : options.activeVersionId, setupCompletedAt: null, updatedAt: new Date('2025-01-01T00:00:00Z'), - } as Agent; + } as ProjectAgent; agentRepository.findById.mockImplementation(async () => ({ ...row, diff --git a/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts index 810fe126f3a..9c0f7c47b6f 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts @@ -7,7 +7,7 @@ import { mock } from 'vitest-mock-extended'; import type { AgentIntegrationManagementService } from '../agent-integration-management.service'; import { AgentIntegrationsController } from '../agent-integrations.controller'; import type { AgentChannelStatus } from '../entities/agent-channel-status.entity'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import type { ChatIntegrationRegistry } from '../integrations/agent-chat-integration'; import type { AgentChannelStatusReporter } from '../integrations/agent-channel-status-reporter'; import type { ChatIntegrationService } from '../integrations/chat-integration.service'; @@ -85,7 +85,7 @@ describe('AgentIntegrationsController integration management', () => { projectId: 'project-1', activeVersionId: 'version-1', integrations: [], - } as unknown as Agent; + } as unknown as ProjectAgent; it('delegates a connect and reports connected for a published agent', async () => { const { controller, managementService, agentRepository } = makeController(); @@ -200,7 +200,7 @@ describe('AgentIntegrationsController integration management', () => { type: 'slack', credentialId: 'credential-1', } satisfies AgentIntegrationConfig; - const draftAgent = { ...agent, activeVersionId: null } as Agent; + const draftAgent = { ...agent, activeVersionId: null } as ProjectAgent; agentRepository.findByIdAndProjectId.mockResolvedValue(draftAgent); managementService.connect.mockResolvedValue({ integration, @@ -575,7 +575,7 @@ describe('AgentIntegrationsController channel status', () => { projectId: 'project-1', activeVersionId: 'version-1', integrations: [slack, telegram], - } as unknown as Agent; + } as unknown as ProjectAgent; function liveRow(integration: AgentIntegrationConfig): AgentChannelStatus { return { @@ -591,7 +591,7 @@ describe('AgentIntegrationsController channel status', () => { } as AgentChannelStatus; } - async function statusOf(agent: Agent, rows: AgentChannelStatus[]) { + async function statusOf(agent: ProjectAgent, rows: AgentChannelStatus[]) { const { controller, agentRepository, channelStatusRepository, statusReporter } = makeController(); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); @@ -630,7 +630,7 @@ describe('AgentIntegrationsController channel status', () => { integrations: [{ type: 'n8n_chat', credentialId: '' }], }, }, - } as Agent; + } as ProjectAgent; const { response } = await statusOf(agent, []); expect(response.n8nChat).toEqual({ draftEnabled: false, publishedEnabled: true }); expect(response.integrations).toEqual([ @@ -643,7 +643,7 @@ describe('AgentIntegrationsController channel status', () => { activeVersionId: null, activeVersion: null, integrations: [...agent.integrations, { type: 'n8n_chat', credentialId: '' }], - } as Agent; + } as ProjectAgent; const { response: unpublishedResponse } = await statusOf(unpublished, []); expect(unpublishedResponse.n8nChat).toEqual({ draftEnabled: true, publishedEnabled: false }); expect(unpublishedResponse.integrations).toContainEqual({ diff --git a/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts index 9d7e8b860d8..aad69a3db5c 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts @@ -26,7 +26,7 @@ import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; import type { AgentValidationService } from '../agent-validation.service'; import type { AgentHistory } from '../entities/agent-history.entity'; import type { AgentTaskSnapshot } from '../entities/agent-task-snapshot.entity'; -import type { Agent } from '../entities/agent.entity'; +import type { Agent, ProjectAgent } from '../entities/agent.entity'; import { ChatIntegrationService } from '../integrations/chat-integration.service'; import type { AgentHistoryRepository } from '../repositories/agent-history.repository'; import type { AgentTaskSnapshotRepository } from '../repositories/agent-task-snapshot.repository'; @@ -54,7 +54,7 @@ const schema: AgentJsonConfig = { instructions: 'Help users', }; -function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { +function makeAgent(overrides: AgentFixtureOverrides = {}): ProjectAgent { return { id: agentId, projectId, @@ -69,7 +69,7 @@ function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { setupCompletedAt: null, revision: 0, ...overrides, - } as unknown as Agent; + } as unknown as ProjectAgent; } function makeHistory(overrides: AgentHistoryFixtureOverrides = {}): AgentHistory { diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts index 3c723087991..15ac2169178 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts @@ -14,7 +14,7 @@ import type { AgentRuntimeReconstructionService } from '../agent-runtime-reconst import { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import { hashAgentSandboxPrincipal } from '../agent-sandbox-principal'; import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service'; -import type { Agent } from '../entities/agent.entity'; +import type { Agent, ProjectAgent } from '../entities/agent.entity'; import type { AgentRepository } from '../repositories/agent.repository'; import type { ToolRegistry } from '../tool-registry'; import { createAgentSkillRefsService, storedRunSource } from './test-utils/stored-agent-config'; @@ -22,7 +22,7 @@ import { createAgentSkillRefsService, storedRunSource } from './test-utils/store const agentId = 'agent-1'; const projectId = 'project-1'; -function makeAgent(overrides: Partial = {}): Agent { +function makeAgent(overrides: Partial = {}): ProjectAgent { return { id: agentId, projectId, @@ -33,7 +33,7 @@ function makeAgent(overrides: Partial = {}): Agent { tools: {}, skills: {}, ...overrides, - } as unknown as Agent; + } as unknown as ProjectAgent; } function makeRuntime() { diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-tool-filtering.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-tool-filtering.test.ts index 76f795f072f..0304fbf0ad6 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-tool-filtering.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-tool-filtering.test.ts @@ -34,7 +34,7 @@ import type { AgentKnowledgeMirrorService } from '../agent-knowledge-mirror.serv import { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service'; import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service'; import type { AgentWorkspaceService } from '../agent-workspace.service'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import { resolveAgentRunSource, type AgentRunSource } from '../utils/agent-published-snapshot'; import { createAgentSkillRefsService, storedAgentConfig } from './test-utils/stored-agent-config'; import { ChatIntegrationRegistry } from '../integrations/agent-chat-integration'; @@ -130,7 +130,7 @@ function makeAgentEntity(tools: AgentJsonToolConfig[]): AgentRunSource { projectId, schema, tools: { custom_tool: { descriptor: { name: 'custom_tool' }, code: '' } }, - } as unknown as Agent; + } as unknown as ProjectAgent; return { agent, skillRefs: undefined, skills: {} }; } @@ -258,7 +258,7 @@ describe('AgentRuntimeReconstructionService — per-user tool filtering', () => tools: {}, skills: {}, }, - } as unknown as Agent; + } as unknown as ProjectAgent; const runSource = await resolveAgentRunSource( agent, diff --git a/packages/cli/src/modules/agents/__tests__/agent-sandbox-runtime.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-sandbox-runtime.service.test.ts index 7139bfe0cc3..0dfea60698b 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-sandbox-runtime.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-sandbox-runtime.service.test.ts @@ -13,7 +13,7 @@ import type { InstanceSettings } from 'n8n-core'; import type { AiService } from '../../../services/ai.service'; import type { SandboxSettingsService } from '../../../services/sandbox-settings.service'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import { hashAgentSandboxPrincipal } from '../agent-sandbox-principal'; import { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service'; import type { AgentRepository } from '../repositories/agent.repository'; @@ -74,7 +74,7 @@ function makeAgentRepository(): ReturnType> { id: agentId, projectId, activeVersionId: null, - } as Agent); + } as ProjectAgent); return repository; } diff --git a/packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts index fe2c72f7bd6..684fb5efb33 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts @@ -15,7 +15,7 @@ import { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import { AgentSaveCompletionService } from '../agent-save-completion.service'; import { AgentSkillsService } from '../agent-skills.service'; import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import { composeJsonConfig } from '../json-config/agent-config-composition'; import type { AgentRepository } from '../repositories/agent.repository'; import type { SkillSaveResult, SkillService } from '../skills/skill.service'; @@ -47,7 +47,7 @@ const skill: AgentSkill = { instructions: 'Extract decisions and action items.', }; -function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { +function makeAgent(overrides: AgentFixtureOverrides = {}): ProjectAgent { return { id: agentId, projectId, @@ -58,7 +58,7 @@ function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { tools: {}, updatedAt: new Date(), ...overrides, - } as unknown as Agent; + } as unknown as ProjectAgent; } function saveResult(overrides: Partial = {}): SkillSaveResult { @@ -520,7 +520,7 @@ describe('AgentSkillsService', () => { schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] }, skills: { skill_a: skill }, }); - const fresh = { ...agent, versionId: 'v2' } as Agent; + const fresh = { ...agent, versionId: 'v2' } as ProjectAgent; agentRepository.findByIdAndProjectId .mockResolvedValueOnce(agent) .mockResolvedValueOnce(fresh); diff --git a/packages/cli/src/modules/agents/__tests__/agent-telemetry.test.ts b/packages/cli/src/modules/agents/__tests__/agent-telemetry.test.ts index 597f0dcddf6..33c0e31cb67 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-telemetry.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-telemetry.test.ts @@ -4,10 +4,13 @@ import { buildAgentConfigurationTelemetry, buildAgentConfigurationTelemetryFromConfig, } from '../agent-telemetry'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import { storedRunSource } from './test-utils/stored-agent-config'; -function makeAgent(schema: AgentJsonConfig | null, overrides: Partial = {}): Agent { +function makeAgent( + schema: AgentJsonConfig | null, + overrides: Partial = {}, +): ProjectAgent { return { id: 'agent-1', schema, @@ -15,7 +18,7 @@ function makeAgent(schema: AgentJsonConfig | null, overrides: Partial = { tools: {}, skills: {}, ...overrides, - } as Agent; + } as ProjectAgent; } describe('agent telemetry', () => { diff --git a/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts index 6d3a38b1ad7..ed78eee3bc3 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts @@ -9,7 +9,7 @@ import type { AiGatewayService } from '@/services/ai-gateway.service'; import type { AgentSkillsService } from '../agent-skills.service'; import { AgentDefinitionService } from '../agent-definition.service'; import { AgentValidationService } from '../agent-validation.service'; -import type { Agent } from '../entities/agent.entity'; +import type { Agent, ProjectAgent } from '../entities/agent.entity'; import type { ChatIntegrationRegistry } from '../integrations/agent-chat-integration'; import type { AgentTaskSnapshotRepository } from '../repositories/agent-task-snapshot.repository'; import type { AgentTaskRepository } from '../repositories/agent-task.repository'; @@ -40,8 +40,8 @@ const executeWorkflowTriggerNode = { function makeAgent( config: AgentJsonConfig | null = runnableConfig, skills = {}, - overrides: Partial = {}, -): Agent { + overrides: Partial = {}, +): ProjectAgent { return { id: agentId, projectId, @@ -51,7 +51,7 @@ function makeAgent( integrations: [], revision: 0, ...overrides, - } as unknown as Agent; + } as unknown as ProjectAgent; } function makeCredentialProvider( diff --git a/packages/cli/src/modules/agents/__tests__/agent-workflow-dependency.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent-workflow-dependency.repository.test.ts index dd180670233..11aec40d57c 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-workflow-dependency.repository.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-workflow-dependency.repository.test.ts @@ -119,6 +119,26 @@ describe('AgentWorkflowDependencyRepository', () => { expect(entityManager.insert).not.toHaveBeenCalled(); }); + it.each(['sqlite', 'postgres'])( + 'removes the rows of an instance agent and does not rebuild them on %s', + async (type) => { + Object.assign(entityManager.connection, { options: { type } }); + entityManager.findOne.mockResolvedValue({ + ...makeAgent(), + scope: 'instance', + projectId: null, + } as Agent); + + await repository.refreshForAgent('agent-1'); + + expect(entityManager.delete).toHaveBeenCalledWith(AgentWorkflowDependency, { + agentId: 'agent-1', + }); + expect(entityManager.find).not.toHaveBeenCalled(); + expect(entityManager.insert).not.toHaveBeenCalled(); + }, + ); + it('locks the agent and the resolved workflows before replacing rows on Postgres', async () => { Object.assign(entityManager.connection, { options: { type: 'postgres' } }); const agent = makeAgent(); diff --git a/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts index e469ea114bb..28067103520 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts @@ -35,7 +35,7 @@ import { import { AgentBudgetAlertService } from '../agent-budget-alert.service'; import { AgentSpendLedger } from '../budget-guardrail'; import { AgentWorkflowExecutionService } from '../agent-workflow-execution.service'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import type { NodeToolAiGatewayService } from '../json-config/node-tool-ai-gateway.service'; import type { AgentRepository } from '../repositories/agent.repository'; import type { IntegrationMessageContextService } from '../integrations/integration-message-context.service'; @@ -56,7 +56,7 @@ const schema: AgentJsonConfig = { instructions: 'Help users', }; -function makeAgent(overrides: Partial = {}): Agent { +function makeAgent(overrides: Partial = {}): ProjectAgent { return { id: agentId, name: 'Support Agent', @@ -67,7 +67,7 @@ function makeAgent(overrides: Partial = {}): Agent { tools: {}, skills: {}, ...overrides, - } as unknown as Agent; + } as unknown as ProjectAgent; } function makeReadableStream(chunks: StreamChunk[]): ReadableStream { @@ -502,7 +502,7 @@ describe('AgentWorkflowExecutionService', () => { async (compilation) => { const { service, agentRepository, reconstructionService, executionService } = makeService(); const lookupStarted = createDeferredPromise(); - const agentLookup = createDeferredPromise(); + const agentLookup = createDeferredPromise(); const runtime = makeRuntime(); const cause = new UserError('Session not found'); executionService.getSessionMode.mockResolvedValue('existing'); @@ -1521,7 +1521,9 @@ describe('AgentWorkflowExecutionService', () => { describe('eval runs', () => { const evalConfig: AgentJsonConfig = { ...schema, instructions: 'Help users (eval copy)' }; - const integrations = [{ type: 'slack', credentialId: 'slack-1' }] as Agent['integrations']; + const integrations = [ + { type: 'slack', credentialId: 'slack-1' }, + ] as ProjectAgent['integrations']; function makePrepareForEval() { const instrumentation: AgentRuntimeInstrumentation = { diff --git a/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts index 4894d736be3..eea2007049b 100644 --- a/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts @@ -28,7 +28,7 @@ describe('AgentRepository', () => { const result = await repository.findByIdAndProjectId('agent-1', 'project-1'); expect(repository.findOne).toHaveBeenCalledWith({ - where: { id: 'agent-1', projectId: 'project-1' }, + where: { id: 'agent-1', projectId: 'project-1', scope: 'project' }, relations: { activeVersion: true }, }); expect(result).toBe(agent); @@ -44,14 +44,14 @@ describe('AgentRepository', () => { }); describe('findById', () => { - it('calls findOne with the id alone and the activeVersion relation', async () => { + it('calls findOne with the id, the project scope and the activeVersion relation', async () => { const agent = mock({ id: 'agent-1' }); vi.spyOn(repository, 'findOne').mockResolvedValue(agent); const result = await repository.findById('agent-1'); expect(repository.findOne).toHaveBeenCalledWith({ - where: { id: 'agent-1' }, + where: { id: 'agent-1', scope: 'project' }, relations: { activeVersion: true }, }); expect(result).toBe(agent); @@ -75,7 +75,7 @@ describe('AgentRepository', () => { const result = await repository.findByIdInProjects('agent-1', ['project-1', 'project-2']); expect(repository.findOne).toHaveBeenCalledWith({ - where: { id: 'agent-1', projectId: In(['project-1', 'project-2']) }, + where: { id: 'agent-1', projectId: In(['project-1', 'project-2']), scope: 'project' }, relations: { activeVersion: true }, }); expect(result).toBe(agent); @@ -90,11 +90,11 @@ describe('AgentRepository', () => { const result = await repository.findByProjectId('project-1'); expect(repository.find).toHaveBeenCalledWith({ - where: { projectId: 'project-1' }, + where: { projectId: 'project-1', scope: 'project' }, relations: { activeVersion: true }, order: { updatedAt: 'DESC' }, }); - expect(result).toBe(agents); + expect(result).toEqual(agents); }); it('returns an empty array when the project has no agents', async () => { @@ -139,10 +139,11 @@ describe('AgentRepository', () => { 'agent.availableInMCP', 'agent.updatedAt', ]); - expect(mockQb.where).toHaveBeenCalledWith('agent.projectId IN (:...projectIds)', { + expect(mockQb.where).toHaveBeenCalledWith("agent.scope = 'project'"); + expect(mockQb.andWhere).toHaveBeenCalledWith('agent.projectId IN (:...projectIds)', { projectIds: ['project-1'], }); - expect(mockQb.andWhere).not.toHaveBeenCalled(); + expect(mockQb.andWhere).toHaveBeenCalledTimes(1); expect(mockQb.take).not.toHaveBeenCalled(); }); @@ -152,7 +153,8 @@ describe('AgentRepository', () => { await repository.findSummariesByProjectIds(null); - expect(mockQb.where).not.toHaveBeenCalled(); + expect(mockQb.where).toHaveBeenCalledWith("agent.scope = 'project'"); + expect(mockQb.andWhere).not.toHaveBeenCalled(); }); it('pushes all filters and the limit into the query', async () => { @@ -209,7 +211,8 @@ describe('AgentRepository', () => { sortBy: 'name:asc', } as never); - expect(mockQb.where).toHaveBeenCalledWith('agent.projectId IN (:...projectIds)', { + expect(mockQb.where).toHaveBeenCalledWith("agent.scope = 'project'"); + expect(mockQb.andWhere).toHaveBeenCalledWith('agent.projectId IN (:...projectIds)', { projectIds: ['project-1'], }); expect(mockQb.skip).toHaveBeenCalledWith(0); @@ -226,7 +229,11 @@ describe('AgentRepository', () => { take: 25, } as never); - expect(mockQb.where).not.toHaveBeenCalled(); + expect(mockQb.where).toHaveBeenCalledWith("agent.scope = 'project'"); + expect(mockQb.andWhere).not.toHaveBeenCalledWith( + 'agent.projectId IN (:...projectIds)', + expect.anything(), + ); }); it('applies the name search filter', async () => { @@ -371,13 +378,14 @@ describe('AgentRepository', () => { }); describe('findMcpAvailabilityCandidates', () => { - it('omits the where clause when all agents are requested', async () => { + it('filters only on the project scope when all agents are requested', async () => { const find = vi.spyOn(repository, 'find').mockResolvedValue([]); await repository.findMcpAvailabilityCandidates({ all: true }); expect(find).toHaveBeenCalledWith({ select: ['id', 'projectId', 'availableInMCP'], + where: { scope: 'project' }, }); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts index 43c4e63cd25..9e6be4cf6b1 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts @@ -46,7 +46,7 @@ import type { AgentsService } from '../agents.service'; import { AgentsBuilderToolsService } from '../builder/agents-builder-tools.service'; import type { BuilderModelLiveLookupService } from '../builder/builder-model-live-lookup.service'; import { BUILDER_TOOLS } from '../builder/builder-tool-names'; -import type { Agent } from '../entities/agent.entity'; +import type { Agent, ProjectAgent } from '../entities/agent.entity'; import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime'; import { getAgentConfigHash } from '../utils/agent-config-hash'; import { SkillNameTakenError } from '../skills/skill-name-taken.error'; @@ -241,7 +241,7 @@ function makeLinearToolWithParameters( }; } -function makeAgent(config: AgentJsonConfig = baseConfig): Agent { +function makeAgent(config: AgentJsonConfig = baseConfig): ProjectAgent { return { schema: config, integrations: [], @@ -249,7 +249,7 @@ function makeAgent(config: AgentJsonConfig = baseConfig): Agent { versionId: 'v1', tools: {}, skills: {}, - } as unknown as Agent; + } as unknown as ProjectAgent; } function makeTaskDto(overrides: Partial = {}): AgentTaskDto { @@ -684,7 +684,10 @@ describe('AgentsBuilderToolsService', () => { credentialId: 'telegram-cred', settings: { accessMode: 'private', allowedUsers: ['@someone'] }, }; - const agent = { ...makeAgent(baseConfig), integrations: [telegram] } as unknown as Agent; + const agent = { + ...makeAgent(baseConfig), + integrations: [telegram], + } as unknown as ProjectAgent; const currentConfig = { ...baseConfig, integrations: [telegram] }; agentsService.findById.mockResolvedValue(agent); agentsService.updateConfig.mockResolvedValue({ @@ -2897,7 +2900,7 @@ describe('AgentsBuilderToolsService', () => { agent: { activeVersionId: 'v-active', versionId: 'v-active', - } as Agent, + } as ProjectAgent, }); const result = await getPublishTool(service).handler!({}, ctx); @@ -2928,7 +2931,7 @@ describe('AgentsBuilderToolsService', () => { agent: { activeVersionId: 'v-history', versionId: 'v-draft', - } as Agent, + } as ProjectAgent, }); const result = await getPublishTool(service).handler!({ versionId: 'v-history' }, ctx); @@ -2955,7 +2958,7 @@ describe('AgentsBuilderToolsService', () => { const publishedAgent = { activeVersionId: 'v-active', versionId: 'v-active', - } as Agent; + } as ProjectAgent; agentsService.findById.mockResolvedValue(publishedAgent); agentPublishService.publishAgent.mockResolvedValue({ agent: publishedAgent }); @@ -3017,7 +3020,7 @@ describe('AgentsBuilderToolsService', () => { vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true); agentPublishService.unpublishAgent.mockResolvedValue({ activeVersionId: null, - } as Agent); + } as ProjectAgent); const result = await getUnpublishTool(service).handler!({}, ctx); diff --git a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts index 645ade85d2f..dcb7b54e298 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts @@ -45,7 +45,7 @@ import type { AgentSandboxRuntimeService, } from '../agent-sandbox-runtime.service'; import type { AgentWorkspaceService } from '../agent-workspace.service'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import { ChatIntegrationRegistry } from '../integrations/agent-chat-integration'; import { ChatIntegrationActionExecutor } from '../integrations/integration-action-executor'; import { ChatIntegrationContextQueryExecutor } from '../integrations/integration-context-query-executor'; @@ -149,7 +149,7 @@ function makeReconstructionService( function makeAgentEntity( schemaConfig?: AgentJsonConfig['config'], overrides?: Partial, -): Agent { +): ProjectAgent { const schema: AgentJsonConfig = { name: 'Test', model: 'anthropic/claude-sonnet-4-5', @@ -162,7 +162,7 @@ function makeAgentEntity( projectId: 'project-1', schema, tools: {}, - } as unknown as Agent; + } as unknown as ProjectAgent; } describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — MCP wiring', () => { @@ -609,7 +609,7 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a id: 'agent-2', name: 'Research Agent', activeVersionId: 'version-2', - } as Agent); + } as ProjectAgent); const service = makeReconstructionService({ agentRepository }); const entity = makeAgentEntity(undefined, { subAgents: { agents: [{ agentId: 'agent-2' }] }, @@ -652,7 +652,7 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a id: 'agent-billing', name: 'Billing Agent', activeVersionId: 'version-billing', - } as Agent); + } as ProjectAgent); const service = makeReconstructionService({ agentRepository }); const entity = makeAgentEntity(undefined, { subAgents: { @@ -686,7 +686,7 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a id: 'agent-billing', name: 'Billing Agent', activeVersionId: 'version-billing', - } as Agent); + } as ProjectAgent); const service = makeReconstructionService({ agentRepository }); const config: AgentJsonConfig = { name: 'Test', @@ -706,7 +706,7 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a id: 'agent-billing', name: 'Billing Agent', activeVersionId: null, - } as Agent); + } as ProjectAgent); const service = makeReconstructionService({ agentRepository }); const config: AgentJsonConfig = { name: 'Test', @@ -1102,7 +1102,7 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — backg function setupWithRoster() { const agentRepository = mock(); agentRepository.findByIdAndProjectId.mockResolvedValue( - mock({ id: 'agent-2', name: 'Researcher', activeVersionId: 'version-1' }), + mock({ id: 'agent-2', name: 'Researcher', activeVersionId: 'version-1' }), ); return { service: makeReconstructionService({ agentRepository }), @@ -1151,7 +1151,9 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — backg it('excludes disabled sub-agents from foreground and background delegation', async () => { Container.get(AgentsConfig).backgroundTasksEnabled = true; const agentRepository = mock(); - agentRepository.findByIdAndProjectId.mockResolvedValue(mock({ id: 'disabled-agent' })); + agentRepository.findByIdAndProjectId.mockResolvedValue( + mock({ id: 'disabled-agent' }), + ); const service = makeReconstructionService({ agentRepository }); await service.reconstructFromAgentEntity( storedRunSource( diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index c798ee434be..f2ad1c801d0 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -19,7 +19,7 @@ import { AgentTaskService } from '../agent-task.service'; import type { AgentTestChatService } from '../agent-test-chat.service'; import { AgentsService } from '../agents.service'; import type { AgentTask } from '../entities/agent-task.entity'; -import type { Agent } from '../entities/agent.entity'; +import type { Agent, ProjectAgent } from '../entities/agent.entity'; import { ChatIntegrationService } from '../integrations/chat-integration.service'; import type { AgentTaskRepository } from '../repositories/agent-task.repository'; import type { AgentRepository } from '../repositories/agent.repository'; @@ -38,7 +38,7 @@ function mockCreateReturning(agentRepository: MockProxy, entity Object.assign(entity, fields)) as never); } -function makeAgent(overrides: Partial = {}): Agent { +function makeAgent(overrides: Partial = {}): ProjectAgent { return { id: agentId, name: 'Agent', @@ -52,7 +52,7 @@ function makeAgent(overrides: Partial = {}): Agent { skills: {}, updatedAt: new Date('2025-01-01T00:00:00Z'), ...overrides, - } as unknown as Agent; + } as unknown as ProjectAgent; } function makeService() { @@ -1216,7 +1216,7 @@ describe('AgentsService', () => { { type: 'telegram', credentialId: 'cred-2' }, ], tools: { c1: { code: '', descriptor: { name: 'Refund tool' } } }, - } as unknown as Partial), + } as unknown as Partial), ); agentTaskRepository.findByAgentId.mockResolvedValue([ { id: 't1', name: 'Daily digest' } as AgentTask, @@ -1283,7 +1283,7 @@ describe('AgentsService', () => { skills: [{ type: 'skill', id: 's-missing' }], tasks: [{ type: 'task', id: 't-missing', enabled: false }], }, - } as unknown as Partial), + } as unknown as Partial), ); agentTaskRepository.findByAgentId.mockResolvedValue([]); diff --git a/packages/cli/src/modules/agents/__tests__/instance-ai-agent-context.adapter.test.ts b/packages/cli/src/modules/agents/__tests__/instance-ai-agent-context.adapter.test.ts index c9e6682a1f9..3ef94a90244 100644 --- a/packages/cli/src/modules/agents/__tests__/instance-ai-agent-context.adapter.test.ts +++ b/packages/cli/src/modules/agents/__tests__/instance-ai-agent-context.adapter.test.ts @@ -16,7 +16,7 @@ import type { AgentsSettingsService } from '../agents-settings.service'; import type { AgentsToolsService } from '../agents-tools.service'; import type { AgentsService } from '../agents.service'; import type { AttachableWorkflowsService } from '../attachable-workflows.service'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import { InstanceAiAgentContextAdapterService } from '../instance-ai-agent-context.adapter'; import { createAgentSkillRefsService, storedAgentConfig } from './test-utils/stored-agent-config'; @@ -73,7 +73,7 @@ const agent = { versionId: 'draft-1', activeVersionId: 'published-1', updatedAt: new Date('2026-09-17T10:00:00.000Z'), -} as unknown as Agent; +} as unknown as ProjectAgent; describe('InstanceAiAgentContextAdapterService', () => { beforeEach(() => { @@ -271,7 +271,7 @@ describe('InstanceAiAgentContextAdapterService', () => { instructions: 'Help users.', skills: [{ type: 'skill', id: 'notes' }], }), - } as Agent); + } as ProjectAgent); const result = await service.createReader(user, 'project-1').lookup({ type: 'skills', diff --git a/packages/cli/src/modules/agents/__tests__/instance-ai-builder-delegate.adapter.test.ts b/packages/cli/src/modules/agents/__tests__/instance-ai-builder-delegate.adapter.test.ts index f5061884273..2aac246817a 100644 --- a/packages/cli/src/modules/agents/__tests__/instance-ai-builder-delegate.adapter.test.ts +++ b/packages/cli/src/modules/agents/__tests__/instance-ai-builder-delegate.adapter.test.ts @@ -19,7 +19,7 @@ import type { AgentsService } from '../agents.service'; import { AgentsSettingsService } from '../agents-settings.service'; import type { AgentsBuilderService } from '../builder/agents-builder.service'; import type { AgentThreadEntity } from '../entities/agent-thread.entity'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import { INSTANCE_AI_BUILDER_ADDENDUM, InstanceAiBuilderDelegateAdapterService, @@ -602,7 +602,7 @@ describe('InstanceAiBuilderDelegateAdapterService', () => { const { delegate, agentsService } = setup(); vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true); agentsService.createOrAdopt.mockResolvedValue({ - agent: mock({ id: 'agent-9', name: 'New agent' }), + agent: mock({ id: 'agent-9', name: 'New agent' }), adopted: false, }); @@ -623,7 +623,7 @@ describe('InstanceAiBuilderDelegateAdapterService', () => { const { delegate, agentsService } = setup(); vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true); agentsService.createOrAdopt.mockResolvedValue({ - agent: mock({ id: 'aBcDeFgHiJkLmNoP', name: 'New agent' }), + agent: mock({ id: 'aBcDeFgHiJkLmNoP', name: 'New agent' }), adopted: false, }); @@ -651,7 +651,7 @@ describe('InstanceAiBuilderDelegateAdapterService', () => { const { delegate, agentsService } = setup(); vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true); agentsService.createOrAdopt.mockResolvedValue({ - agent: mock({ id: 'aBcDeFgHiJkLmNoP', name: 'Support Triage' }), + agent: mock({ id: 'aBcDeFgHiJkLmNoP', name: 'Support Triage' }), adopted: true, }); @@ -688,7 +688,9 @@ describe('InstanceAiBuilderDelegateAdapterService', () => { it('returns the agent display name', async () => { const { delegate, agentsService } = setup(); vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true); - agentsService.findById.mockResolvedValue(mock({ id: 'agent-1', name: 'Support Bot' })); + agentsService.findById.mockResolvedValue( + mock({ id: 'agent-1', name: 'Support Bot' }), + ); await expect(delegate.resolveAgentName('agent-1')).resolves.toBe('Support Bot'); expect(agentsService.findById).toHaveBeenCalledWith('agent-1', 'project-1'); diff --git a/packages/cli/src/modules/agents/__tests__/test-utils/stored-agent-config.ts b/packages/cli/src/modules/agents/__tests__/test-utils/stored-agent-config.ts index c6253de24b5..2c083d817ac 100644 --- a/packages/cli/src/modules/agents/__tests__/test-utils/stored-agent-config.ts +++ b/packages/cli/src/modules/agents/__tests__/test-utils/stored-agent-config.ts @@ -152,9 +152,9 @@ export function composeStoredJsonConfig( * A run source for an agent fixture, with the skill refs that its stored * config holds and the given skill bodies. */ -export function storedRunSource( - agent: Agent, +export function storedRunSource( + agent: T, skills: Record = {}, -): AgentRunSource { +): AgentRunSource { return { agent, skillRefs: storedSkillRefs(agent), skills }; } diff --git a/packages/cli/src/modules/agents/agent-config.service.ts b/packages/cli/src/modules/agents/agent-config.service.ts index 49be52d14f4..16643f2dd0b 100644 --- a/packages/cli/src/modules/agents/agent-config.service.ts +++ b/packages/cli/src/modules/agents/agent-config.service.ts @@ -22,7 +22,7 @@ import { AgentPolicyService } from './agent-policy.service'; import { AgentSaveCompletionService } from './agent-save-completion.service'; import { AgentSetupCompletionService } from './agent-setup-completion.service'; import { AgentSkillRefsService } from './agent-skill-refs.service'; -import type { Agent } from './entities/agent.entity'; +import type { Agent, ProjectAgent } from './entities/agent.entity'; import { syncAgentIntegrations } from './integrations/integrations-sync'; import { composeJsonConfig, decomposeJsonConfig } from './json-config/agent-config-composition'; import { @@ -177,7 +177,7 @@ export class AgentConfigService { } private async finishConfigUpdate( - saved: Agent, + saved: ProjectAgent, validatedConfig: AgentJsonConfig, replacement: ConfigReplacement, clearOmitted: boolean, @@ -202,7 +202,7 @@ export class AgentConfigService { } private async removeUnreferencedTasks( - agent: Agent, + agent: ProjectAgent, existingTaskIds: string[], ctx: OperationContext, ): Promise { @@ -214,7 +214,7 @@ export class AgentConfigService { } private async saveConfig( - entity: Agent, + entity: ProjectAgent, skillRefs: AgentSkillRefs, credentialProvider: AgentsCredentialProvider, user: User, @@ -259,7 +259,7 @@ export class AgentConfigService { } private removeUnreferencedResources( - entity: Agent, + entity: ProjectAgent, config: AgentJsonConfig, clearOmitted: boolean, ): void { @@ -276,7 +276,7 @@ export class AgentConfigService { } } - private removeUnreferencedCustomTools(entity: Agent, config: AgentJsonConfig): void { + private removeUnreferencedCustomTools(entity: ProjectAgent, config: AgentJsonConfig): void { const referencedIds = new Set( (config.tools ?? []) .filter( @@ -293,7 +293,7 @@ export class AgentConfigService { } private buildConfigReplacement( - entity: Agent, + entity: ProjectAgent, previousSchema: AgentJsonConfig | null, validatedConfig: AgentJsonConfig, rawConfig: unknown, @@ -360,7 +360,7 @@ export class AgentConfigService { } private async reconcileConfigReferences( - entity: Agent, + entity: ProjectAgent, previousSchema: AgentJsonConfig | null, config: AgentJsonConfig, clearOmitted: boolean, @@ -379,7 +379,7 @@ export class AgentConfigService { private async reconcileSubAgentReferences( config: AgentJsonConfig, - entity: Agent, + entity: ProjectAgent, ): Promise { if (config.subAgents?.agents !== undefined) { const existingAgentIds = new Set( @@ -399,7 +399,7 @@ export class AgentConfigService { return []; } - private validateSubAgentRefs(resolvedSubAgents: ResolvedSubAgentRef[], entity: Agent) { + private validateSubAgentRefs(resolvedSubAgents: ResolvedSubAgentRef[], entity: ProjectAgent) { for (const { agentId } of resolvedSubAgents) { if (agentId === entity.id) { throw new UserError('Invalid agent config: An agent cannot use itself as a subagent'); diff --git a/packages/cli/src/modules/agents/agent-execution.service.ts b/packages/cli/src/modules/agents/agent-execution.service.ts index b9719a35193..bdce8c59d9f 100644 --- a/packages/cli/src/modules/agents/agent-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-execution.service.ts @@ -40,6 +40,7 @@ import { type AgentThreadAccess, } from './entities/agent-execution-thread.entity'; import { AgentExecution, type AgentExecutionHitlStatus } from './entities/agent-execution.entity'; +import { isProjectAgent } from './entities/agent.entity'; import type { MessageRecord, TimelineEvent } from './execution-recorder'; import { AgentExecutionLogStore, @@ -1306,6 +1307,10 @@ function executionStatus(record: MessageRecord): AgentExecution['status'] { /** Maps a thread (with `agent` and `agent.activeVersion` loaded) to the * cross-agent n8n Chat thread list shape. */ function toN8nChatThreadSummary(thread: AgentExecutionThread): AgentN8nChatThreadSummary { + // The callers narrow `agentIds` to chat-reachable project agents. + if (!isProjectAgent(thread.agent)) { + throw new UnexpectedError(`Thread ${thread.id} does not belong to a project agent`); + } return { id: thread.id, title: thread.title, diff --git a/packages/cli/src/modules/agents/agent-integration-management.service.ts b/packages/cli/src/modules/agents/agent-integration-management.service.ts index f30eccb1211..3d3bd28df7c 100644 --- a/packages/cli/src/modules/agents/agent-integration-management.service.ts +++ b/packages/cli/src/modules/agents/agent-integration-management.service.ts @@ -21,14 +21,14 @@ import { } from './agent-integration-persistence.service'; import type { AgentActor } from './agent-modification-telemetry.service'; import { AgentUpdateBroadcaster } from './agent-update-broadcaster'; -import type { Agent } from './entities/agent.entity'; +import type { ProjectAgent } from './entities/agent.entity'; import { ChatIntegrationRegistry } from './integrations/agent-chat-integration'; import { ChatIntegrationService } from './integrations/chat-integration.service'; import { AgentRepository } from './repositories/agent.repository'; import type { IntegrationRef } from './utils/agent-channel'; interface IntegrationChangeOptions extends IntegrationDelta { - agent: Agent; + agent: ProjectAgent; user: User; cleanupRemovedIntegration?: boolean; deleteExternalResource?: boolean; @@ -73,13 +73,13 @@ export class AgentIntegrationManagementService { * failure can leave the agent with two live channels or none. */ async connect(options: { - agent: Agent; + agent: ProjectAgent; user: User; integration: unknown; replaces?: IntegrationRef; modifiedBy?: AgentActor; pushRef?: string; - }): Promise<{ integration: AgentIntegrationConfig; savedAgent: Agent }> { + }): Promise<{ integration: AgentIntegrationConfig; savedAgent: ProjectAgent }> { const integration = await this.validateConfig(options.integration); // n8n Chat has no credential to look up or claim. if (isCredentialAgentIntegration(integration)) { @@ -99,14 +99,14 @@ export class AgentIntegrationManagementService { } async disconnect(options: { - agent: Agent; + agent: ProjectAgent; user: User; type: string; credentialId: string; deleteExternalResource?: boolean; modifiedBy?: AgentActor; pushRef?: string; - }): Promise<{ savedAgent: Agent; warning?: AgentIntegrationDisconnectWarning }> { + }): Promise<{ savedAgent: ProjectAgent; warning?: AgentIntegrationDisconnectWarning }> { const result = await this.applyChange({ agent: options.agent, user: options.user, @@ -275,7 +275,7 @@ export class AgentIntegrationManagementService { * `connect` broadcast — while unpublished, which must never receive events. */ private async reconcileRuntimeWithPublication( - agent: Agent, + agent: ProjectAgent, add: AgentIntegrationConfig, connected: boolean, published: boolean, @@ -316,7 +316,10 @@ export class AgentIntegrationManagementService { * Start a runtime for a channel that is already durable, so a failure must not * fail the request — the next publish or restart picks it up. */ - private async startRuntimeQuietly(agent: Agent, add: AgentIntegrationConfig): Promise { + private async startRuntimeQuietly( + agent: ProjectAgent, + add: AgentIntegrationConfig, + ): Promise { try { return await this.startRuntime(agent, add, true); } catch (error) { @@ -339,7 +342,7 @@ export class AgentIntegrationManagementService { * differs before and after the write. */ private async startRuntime( - agent: Agent, + agent: ProjectAgent, add: AgentIntegrationConfig, published: boolean, ): Promise { @@ -364,7 +367,7 @@ export class AgentIntegrationManagementService { * under it. Best-effort: the original failure is what the caller reports. */ private async restorePersistedRuntime( - agent: Agent, + agent: ProjectAgent, persisted: AgentIntegrationConfig | undefined, ): Promise { if (!persisted) return; @@ -387,7 +390,7 @@ export class AgentIntegrationManagementService { * connection to clear, so tear the runtime down either way. */ private async releaseRemoved( - agent: Agent, + agent: ProjectAgent, remove: IntegrationRef, result: IntegrationDeltaResult, ): Promise { @@ -420,7 +423,10 @@ export class AgentIntegrationManagementService { * A leader-routed teardown can time out, and neither caller can act on that — * reporting it would replace the failure that actually matters. */ - private async releaseRuntimeQuietly(agent: Agent, integration: IntegrationRef): Promise { + private async releaseRuntimeQuietly( + agent: ProjectAgent, + integration: IntegrationRef, + ): Promise { try { await this.chatService.disconnect(agent.id, integration); } catch (error) { @@ -436,7 +442,7 @@ export class AgentIntegrationManagementService { } private async assertUsableCredential( - agent: Agent, + agent: ProjectAgent, user: User, integration: AgentIntegrationConfig, ): Promise { diff --git a/packages/cli/src/modules/agents/agent-integration-persistence.service.ts b/packages/cli/src/modules/agents/agent-integration-persistence.service.ts index e06f9be6c27..184cb9526e7 100644 --- a/packages/cli/src/modules/agents/agent-integration-persistence.service.ts +++ b/packages/cli/src/modules/agents/agent-integration-persistence.service.ts @@ -21,7 +21,7 @@ import { import { AgentRuntimeCacheService } from './agent-runtime-cache.service'; import { AgentSetupCompletionService } from './agent-setup-completion.service'; import { AgentSkillRefsService } from './agent-skill-refs.service'; -import type { Agent } from './entities/agent.entity'; +import type { Agent, ProjectAgent } from './entities/agent.entity'; import { toAgentDocument, type AgentSkillRefs } from './json-config/agent-document'; import { ChatIntegrationRegistry } from './integrations/agent-chat-integration'; import { AgentRepository } from './repositories/agent.repository'; @@ -43,7 +43,7 @@ export interface IntegrationDelta { } export interface IntegrationDeltaResult { - agent: Agent; + agent: ProjectAgent; /** False when the delta was already satisfied — nothing was written. */ changed: boolean; /** The entry that was actually removed, for the caller's runtime teardown. */ @@ -132,7 +132,7 @@ export class AgentIntegrationPersistenceService { * Leave runtime connections to the caller. Emit effects after the write. */ async applyIntegrationDelta( - agent: Agent, + agent: ProjectAgent, delta: IntegrationDelta, context: CredentialIntegrationMutationContext, ): Promise { @@ -180,7 +180,7 @@ export class AgentIntegrationPersistenceService { } private recordIntegrationMutation( - agent: Agent, + agent: ProjectAgent, skillRefs: AgentSkillRefs, previousIntegrations: AgentIntegrationConfig[], context: CredentialIntegrationMutationContext, @@ -203,7 +203,7 @@ export class AgentIntegrationPersistenceService { }); } private async applyIntegrationAttempt( - agent: Agent, + agent: ProjectAgent, { add, remove }: IntegrationDelta, context: CredentialIntegrationMutationContext, credentialProvider: ReturnType, @@ -247,7 +247,7 @@ export class AgentIntegrationPersistenceService { } private async persistIntegrations( - agent: Agent, + agent: ProjectAgent, skillRefs: AgentSkillRefs, integrations: AgentIntegrationConfig[], state: Pick, diff --git a/packages/cli/src/modules/agents/agent-mcp-access.service.ts b/packages/cli/src/modules/agents/agent-mcp-access.service.ts index 55c513465d9..55a4ceebef6 100644 --- a/packages/cli/src/modules/agents/agent-mcp-access.service.ts +++ b/packages/cli/src/modules/agents/agent-mcp-access.service.ts @@ -6,7 +6,7 @@ import { CollaborationService } from '@/collaboration/collaboration.service'; import { BadRequestError } from '@n8n/errors'; import { ProjectScopeService } from '@n8n/backend-services'; -import type { Agent } from './entities/agent.entity'; +import type { Agent, ProjectScoped } from './entities/agent.entity'; import { AgentRepository, type AgentListResult } from './repositories/agent.repository'; const BULK_CHUNK_SIZE = 500; @@ -117,7 +117,7 @@ export class AgentMcpAccessService { private async resolveCandidates( dto: UpdateAgentsMcpAvailabilityDto, projectIds: string[] | null, - ): Promise>> { + ): Promise>>> { if (dto.agentIds) { return await this.agentRepository.findMcpAvailabilityCandidates({ ids: [...new Set(dto.agentIds)], diff --git a/packages/cli/src/modules/agents/agent-publish.service.ts b/packages/cli/src/modules/agents/agent-publish.service.ts index d5dce3b726d..8bc2cc88af3 100644 --- a/packages/cli/src/modules/agents/agent-publish.service.ts +++ b/packages/cli/src/modules/agents/agent-publish.service.ts @@ -44,7 +44,7 @@ import { AgentUpdateBroadcaster } from './agent-update-broadcaster'; import { AgentValidationService } from './agent-validation.service'; import type { AgentHistory } from './entities/agent-history.entity'; import { AgentTask } from './entities/agent-task.entity'; -import type { Agent } from './entities/agent.entity'; +import type { Agent, ProjectAgent } from './entities/agent.entity'; import { ChatIntegrationService } from './integrations/chat-integration.service'; import { toAgentDocument } from './json-config/agent-document'; import { AgentHistoryRepository } from './repositories/agent-history.repository'; @@ -94,7 +94,7 @@ function draftSchemaFromVersion(schema: AgentJsonConfig | null): AgentJsonConfig } export interface PublishAgentResult { - agent: Agent; + agent: ProjectAgent; /** * The draft validation `assertPublishable` already computed while * guarding this publish call — only present when the current draft (not @@ -198,7 +198,7 @@ export class AgentPublishService { * The n8n Chat entry is saved in the published schema. */ private async assertPublishable( - agent: Agent, + agent: ProjectAgent, projectId: string, user: User, tasks: ReadonlyMap, @@ -255,7 +255,7 @@ export class AgentPublishService { * them by this point, and skipping them keeps that the single place that owns * the rule. */ - private async assertChannelsStartable(agent: Agent, projectId: string): Promise { + private async assertChannelsStartable(agent: ProjectAgent, projectId: string): Promise { const chatIntegrationService = Container.get(ChatIntegrationService); for (const integration of agent.integrations ?? []) { if (!isCredentialAgentIntegration(integration)) continue; @@ -270,7 +270,7 @@ export class AgentPublishService { user: User, by: AgentActor, pushRef?: string, - ): Promise { + ): Promise { const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId); // Same optimistic revision fence as publish: a concurrent edit that bumped @@ -311,7 +311,7 @@ export class AgentPublishService { * a lookup map would widen the event to a union its payload cannot satisfy. */ private trackPublished( - agent: Agent, + agent: ProjectAgent, projectId: string, user: User, emitter: AgentPublishEmitter, @@ -382,7 +382,7 @@ export class AgentPublishService { user: User, modifiedBy: AgentActor, pushRef?: string, - ): Promise { + ): Promise { const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId); const activeVersion = agent.activeVersion; @@ -407,7 +407,7 @@ export class AgentPublishService { user: User, modifiedBy: AgentActor, pushRef?: string, - ): Promise { + ): Promise { const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId); const version = await this.agentHistoryRepository.findByVersionAndAgentId(versionId, agentId); @@ -423,7 +423,7 @@ export class AgentPublishService { } /** A revert writes an old version back as the draft, so it is policed as a save. */ - private async enforceRevertPolicy(agent: Agent, user: User, schema: Agent['schema']) { + private async enforceRevertPolicy(agent: ProjectAgent, user: User, schema: Agent['schema']) { if (!schema) return; await this.agentPolicyService.enforceSave(agent.projectId, agent.id, schema, agent.schema, { kind: 'user', @@ -433,7 +433,7 @@ export class AgentPublishService { /** Restore versioned content and keep current credential-backed integrations. */ private async restoreVersion( - agent: Agent, + agent: ProjectAgent, version: AgentHistory, nextVersionId: string, context: AgentMutationTelemetryContext, @@ -500,7 +500,7 @@ export class AgentPublishService { agentId: string, projectId: string, versionId: string, - ): Promise<{ agent: Agent; version: AgentHistory; definition: AgentDefinition }> { + ): Promise<{ agent: ProjectAgent; version: AgentHistory; definition: AgentDefinition }> { const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId); const version = await this.agentHistoryRepository.findByVersionAndAgentId(versionId, agentId); @@ -591,7 +591,7 @@ export class AgentPublishService { : null; } - private async startPublishedServices(agent: Agent): Promise { + private async startPublishedServices(agent: ProjectAgent): Promise { const agentId = agent.id; const credentialIntegrations = (agent.integrations ?? []).filter(isCredentialAgentIntegration); if (credentialIntegrations.length > 0) { @@ -613,7 +613,7 @@ export class AgentPublishService { ); } - private async commitUnpublication(agent: Agent, expectedRevision: number): Promise { + private async commitUnpublication(agent: ProjectAgent, expectedRevision: number): Promise { await this.transactionRunner.run({}, async (ctx) => { const nextVersionId = uuid(); @@ -648,7 +648,7 @@ export class AgentPublishService { } private async commitPublication( - agent: Agent, + agent: ProjectAgent, expectedRevision: number, user: User, tasks: ReadonlyMap, @@ -674,7 +674,7 @@ export class AgentPublishService { private async preparePublishedVersion( ctx: OperationContext, - agent: Agent, + agent: ProjectAgent, user: User, tasks: ReadonlyMap, targetHistory?: AgentHistory, @@ -694,7 +694,7 @@ export class AgentPublishService { private async saveDraftHistory( ctx: OperationContext, - agent: Agent, + agent: ProjectAgent, user: User, versionId: string, ) { diff --git a/packages/cli/src/modules/agents/agent-task.service.ts b/packages/cli/src/modules/agents/agent-task.service.ts index 52f185f146e..00ab7534289 100644 --- a/packages/cli/src/modules/agents/agent-task.service.ts +++ b/packages/cli/src/modules/agents/agent-task.service.ts @@ -25,7 +25,7 @@ import { AgentSkillRefsService } from './agent-skill-refs.service'; import { AgentsSettingsService } from './agents-settings.service'; import { AgentTaskJobRegistrar } from './scheduling/agent-task-job-registrar'; import { knownTaskTimezone } from './scheduling/task-timezone'; -import { Agent } from './entities/agent.entity'; +import { isProjectAgent, type ProjectAgent } from './entities/agent.entity'; import { AgentTask } from './entities/agent-task.entity'; import type { AgentTaskSnapshot } from './entities/agent-task-snapshot.entity'; import { isValidCronExpression } from './integrations/cron-validation'; @@ -140,7 +140,7 @@ export class AgentTaskService { projectId: string, dtos: CreateAgentTaskDto[], context: AgentMutationTelemetryContext, - ): Promise<{ tasks: AgentTaskDto[]; agent: Agent; skillRefs: AgentSkillRefs }> { + ): Promise<{ tasks: AgentTaskDto[]; agent: ProjectAgent; skillRefs: AgentSkillRefs }> { if (dtos.length === 0) { throw new BadRequestError('At least one task is required'); } @@ -271,7 +271,7 @@ export class AgentTaskService { this.logger.debug('[AgentTaskService] Deleted task', { agentId, taskId }); } - private attachTaskRef(agent: Agent, taskId: string, enabled: boolean): void { + private attachTaskRef(agent: ProjectAgent, taskId: string, enabled: boolean): void { if (!agent.schema) throw new BadRequestError('Agent has no config yet'); agent.schema.tasks = [ ...(agent.schema.tasks ?? []).filter((ref) => ref.id !== taskId), @@ -317,7 +317,7 @@ export class AgentTaskService { where: { id: agentId }, relations: { activeVersion: true }, }); - if (!agent?.activeVersionId) { + if (!agent?.activeVersionId || !isProjectAgent(agent)) { this.deregisterAgentTasks(agentId); return; } @@ -355,10 +355,12 @@ export class AgentTaskService { // nothing to rebuild on takeover. if (this.durableJobRegistrar.isEnabled()) return; - const agents = await this.agentRepository.find({ - where: { activeVersionId: Not(IsNull()) }, - relations: { activeVersion: true }, - }); + const agents = ( + await this.agentRepository.find({ + where: { activeVersionId: Not(IsNull()) }, + relations: { activeVersion: true }, + }) + ).filter(isProjectAgent); this.logger.debug('[AgentTaskService] Reconnecting published agents', { count: agents.length }); for (const agent of agents) { try { @@ -379,7 +381,7 @@ export class AgentTaskService { } /** Register the agent's published + enabled tasks; stop any that no longer qualify. */ - private async reconcileAgent(agent: Agent): Promise { + private async reconcileAgent(agent: ProjectAgent): Promise { // Only the leader owns task crons — a cron firing on multiple mains would // run the agent twice per tick. Followers skip registration entirely; the // leader applies the change via the `agent-tasks-changed` pubsub reconcile. @@ -481,7 +483,7 @@ export class AgentTaskService { const snapshot = agent?.activeVersionId ? await this.taskSnapshotRepository.findByVersionAndTaskId(agent.activeVersionId, taskId) : null; - if (!agent?.activeVersionId || !snapshot?.enabled) { + if (!agent?.activeVersionId || !snapshot?.enabled || !isProjectAgent(agent)) { this.logger.warn('[AgentTaskService] Task fired but is no longer published and enabled', { taskId, agentId, @@ -529,7 +531,7 @@ export class AgentTaskService { }, TASK_RUN_LOCK_RENEW_MS); } - private async runTask(agent: Agent, snapshot: AgentTaskSnapshot): Promise { + private async runTask(agent: ProjectAgent, snapshot: AgentTaskSnapshot): Promise { const { id: agentId, projectId } = agent; const { taskId } = snapshot; const { message, threadId } = this.buildTaskRunMessage( @@ -621,7 +623,7 @@ export class AgentTaskService { async runNow(agentId: string, taskId: string, user: User): Promise { const task = await this.getOrThrow(agentId, taskId); const agent = await this.agentRepository.findOne({ where: { id: agentId } }); - if (!agent) { + if (!agent || !isProjectAgent(agent)) { throw new NotFoundError(`Agent "${agentId}" not found`); } @@ -724,7 +726,7 @@ export class AgentTaskService { } private async runScheduledTask( - agent: Agent, + agent: ProjectAgent, snapshot: AgentTaskSnapshot, lock: AgentTaskRunLockHandle, ): Promise { diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index 83291e5b795..824276c0400 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -37,6 +37,7 @@ import { v4 as uuid } from 'uuid'; import { CredentialsService } from '@/credentials/credentials.service'; import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import { ConflictError } from '@n8n/errors'; +import { UnexpectedError } from 'n8n-workflow'; import { getAgentOrThrow } from './utils/get-agent-or-throw'; import { AgentChatAttachmentService } from './agent-chat-attachment.service'; @@ -47,7 +48,7 @@ import { AgentRuntimeCacheService } from './agent-runtime-cache.service'; import { AgentSkillRefsService } from './agent-skill-refs.service'; import { AgentTestChatService } from './agent-test-chat.service'; import { AgentsSettingsService } from './agents-settings.service'; -import { Agent } from './entities/agent.entity'; +import { Agent, type ProjectAgent } from './entities/agent.entity'; import { ChatIntegrationService } from './integrations/chat-integration.service'; import { decomposeJsonConfig } from './json-config/agent-config-composition'; import { fromAgentDocument } from './json-config/agent-document'; @@ -133,7 +134,11 @@ export class AgentsService { * dedicated "User duplicated agent" event for that case (carrying the * source agent id), mirroring "User duplicated workflow". */ - async create(projectId: string, name: string, options: CreateAgentOptions): Promise { + async create( + projectId: string, + name: string, + options: CreateAgentOptions, + ): Promise { return (await this.createOrAdopt(projectId, name, options)).agent; } @@ -157,7 +162,7 @@ export class AgentsService { tools, user, }: CreateAgentOptions, - ): Promise<{ agent: Agent; adopted: boolean }> { + ): Promise<{ agent: ProjectAgent; adopted: boolean }> { await this.settingsService.assertEnabled(); const { schemaConfig, integrations } = await this.prepareInitialConfig(projectId, name, { schema, @@ -167,18 +172,22 @@ export class AgentsService { await this.agentPolicyService.enforceSave(projectId, null, schemaConfig, null, actor); const { config: storedConfig, skillRefs } = fromAgentDocument(schemaConfig); - const agent = this.agentRepository.create({ - ...(id ? { id } : {}), - name, - projectId, - schema: storedConfig, - ...(integrations.length > 0 ? { integrations } : {}), - ...(tools ? { tools: tools as Agent['tools'] } : {}), - versionId: uuid(), - availableInMCP, - }); + // `create` types `projectId` as nullable; assigning it again keeps the project type. + const agent = Object.assign( + this.agentRepository.create({ + ...(id ? { id } : {}), + name, + projectId, + schema: storedConfig, + ...(integrations.length > 0 ? { integrations } : {}), + ...(tools ? { tools: tools as Agent['tools'] } : {}), + versionId: uuid(), + availableInMCP, + }), + { projectId }, + ); - let saved: Agent; + let saved: ProjectAgent; try { // `insertNew`, not `save`: `save` would update the row a reused // client-minted id names instead of letting the primary key reject it. @@ -261,7 +270,7 @@ export class AgentsService { return await this.agentRepository.findByProjectIdsPaginated([projectId], options); } - async findById(agentId: string, projectId: string): Promise { + async findById(agentId: string, projectId: string): Promise { return await this.agentRepository.findByIdAndProjectId(agentId, projectId); } @@ -346,7 +355,7 @@ export class AgentsService { * scopes (instance owners/admins) grant access without an explicit project * relation. */ - async findByIdForUser(agentId: string, user: User): Promise { + async findByIdForUser(agentId: string, user: User): Promise { if (hasGlobalScope(user, 'agent:read')) { return await this.agentRepository.findById(agentId); } @@ -565,7 +574,7 @@ export class AgentsService { projectId: string, adoptOnCollision: boolean, error: unknown, - ): Promise { + ): Promise { if (!id || !isUniqueConstraintError(error)) throw error; // Never disclose whether the id exists in another project. const conflict = new ConflictError('An agent with this id already exists'); @@ -578,7 +587,7 @@ export class AgentsService { return existing; } - private getCapabilityTools(entity: Agent): AgentCapabilityTool[] { + private getCapabilityTools(entity: ProjectAgent): AgentCapabilityTool[] { return (entity.schema?.tools ?? []).flatMap((tool) => { switch (tool.type) { case 'custom': @@ -603,7 +612,7 @@ export class AgentsService { }); } - private async getCapabilityTasks(entity: Agent): Promise { + private async getCapabilityTasks(entity: ProjectAgent): Promise { const taskRefs = entity.schema?.tasks ?? []; let taskNamesById: Record = {}; if (taskRefs.length > 0) { @@ -664,9 +673,10 @@ export class AgentsService { } /** Keeps the chat list to what the page renders: icon, blurb, and attachment support from the published snapshot. */ -function toChatListItem(agent: Agent): AgentChatListItem { +function toChatListItem(agent: ProjectAgent): AgentChatListItem { const schema = agent.activeVersion?.schema; const description = schema?.description; + if (!agent.project) throw new UnexpectedError('Agent project relation is not loaded'); return { ...toAgentRef(agent), ...(description ? { description } : {}), diff --git a/packages/cli/src/modules/agents/background/agent-wake.service.ts b/packages/cli/src/modules/agents/background/agent-wake.service.ts index 032b1becc8a..47dff0da322 100644 --- a/packages/cli/src/modules/agents/background/agent-wake.service.ts +++ b/packages/cli/src/modules/agents/background/agent-wake.service.ts @@ -22,7 +22,7 @@ import { AGENT_BACKGROUND_UPDATES_OPEN_TAG, formatWakeMessage, } from './background-job-messages'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import type { AgentBackgroundJob } from '../entities/agent-background-job.entity'; import { ChatIntegrationRegistry } from '../integrations/agent-chat-integration'; import { AgentBackgroundJobRepository } from '../repositories/agent-background-job.repository'; @@ -327,7 +327,7 @@ export class AgentWakeService { } private async runWake( - agent: Agent, + agent: ProjectAgent, identity: ExecuteForWakeConfig['identity'], jobs: AgentBackgroundJob[], threadId: string, diff --git a/packages/cli/src/modules/agents/builder/__tests__/agents-builder.service.session.test.ts b/packages/cli/src/modules/agents/builder/__tests__/agents-builder.service.session.test.ts index b9cd50d3d32..47b30669389 100644 --- a/packages/cli/src/modules/agents/builder/__tests__/agents-builder.service.session.test.ts +++ b/packages/cli/src/modules/agents/builder/__tests__/agents-builder.service.session.test.ts @@ -9,7 +9,7 @@ import type { NodeCatalogService } from '@/node-catalog'; import type { InstanceAiCreditService } from '../../../instance-ai/instance-ai-credit.service'; import type { AgentsService } from '../../agents.service'; -import type { Agent as AgentEntity } from '../../entities/agent.entity'; +import type { ProjectAgent } from '../../entities/agent.entity'; import type { N8NCheckpointStorage } from '../../integrations/n8n-checkpoint-storage'; import type { N8nMemory, N8nMemoryImpl } from '../../integrations/n8n-memory'; import type { AgentsBuilderToolsService } from '../agents-builder-tools.service'; @@ -188,7 +188,7 @@ function setup( memoryImplementation.getMessages.mockResolvedValue([]); n8nMemory.getImplementation.mockReturnValue(memoryImplementation); - const agent = mock({ + const agent = mock({ id: 'agent-1', schema: null, integrations: [], diff --git a/packages/cli/src/modules/agents/entities/agent.entity.ts b/packages/cli/src/modules/agents/entities/agent.entity.ts index fae6a2b4200..91e124161d0 100644 --- a/packages/cli/src/modules/agents/entities/agent.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent.entity.ts @@ -5,17 +5,47 @@ import { Column, Entity, ManyToOne, JoinColumn, type Relation } from '@n8n/typeo import type { AgentHistory } from './agent-history.entity'; +export type AgentScope = 'project' | 'instance'; + +/** An agent row (or a column subset of one) that belongs to a project. */ +export type ProjectScoped> = T & { projectId: string }; + +/** A project agent. Project-scoped repository queries return only these. */ +export type ProjectAgent = ProjectScoped; + +/** + * Narrows an agent to a project agent. Project-scoped repository queries + * filter on `scope` in SQL too; this guard gives callers the non-null + * `projectId` without a cast. A column subset without `scope` counts as a + * project agent when it has a project. + */ +export function isProjectAgent>( + agent: T, +): agent is ProjectScoped { + if ('scope' in agent && agent.scope === 'instance') return false; + return agent.projectId !== null; +} + @Entity({ name: 'agents' }) export class Agent extends WithTimestampsAndStringId { @Column({ type: 'varchar', length: 128 }) name: string; - @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @ManyToOne(() => Project, { onDelete: 'CASCADE', nullable: true }) @JoinColumn({ name: 'projectId' }) - project: Project; + project: Project | null; - @Column() - projectId: string; + /** Null only for an instance agent (`scope = 'instance'`). Its threads carry the working project. */ + @Column({ type: 'varchar', length: 255, nullable: true }) + projectId: string | null; + + /** + * `project`: a user-defined agent in `projectId`. `instance`: a code-defined + * agent that belongs to no project. The project-agent APIs do not see + * instance agents: reads and writes report them as not found. + */ + @Column({ type: 'varchar', length: 16, default: 'project' }) + scope: AgentScope; @JsonColumn({ nullable: true, default: null }) schema: StoredAgentConfig | null; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-channel-reconciler.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-channel-reconciler.service.test.ts index c95198a07ee..0af15654f8f 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-channel-reconciler.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-channel-reconciler.service.test.ts @@ -7,7 +7,7 @@ import type { ErrorReporter, InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; import type { AgentChannelRef } from '../../utils/agent-channel'; -import type { Agent } from '../../entities/agent.entity'; +import type { ProjectAgent } from '../../entities/agent.entity'; import type { AgentChannelStatus } from '../../entities/agent-channel-status.entity'; import type { AgentChannelStatusRepository } from '../../repositories/agent-channel-status.repository'; import type { AgentRepository } from '../../repositories/agent.repository'; @@ -45,8 +45,8 @@ class FakeIntegration extends AgentChatIntegration { const slack: AgentIntegrationConfig = { type: 'slack', credentialId: 'cred-slack' }; const telegram: AgentIntegrationConfig = { type: 'telegram', credentialId: 'cred-telegram' }; -function makeAgent(integrations: AgentIntegrationConfig[], id = 'agent-1'): Agent { - return { id, projectId: 'project-1', integrations } as unknown as Agent; +function makeAgent(integrations: AgentIntegrationConfig[], id = 'agent-1'): ProjectAgent { + return { id, projectId: 'project-1', integrations } as unknown as ProjectAgent; } function refOf(integration: AgentIntegrationConfig, agentId = 'agent-1'): AgentChannelRef { diff --git a/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts index 53173d58c0b..92d592111b8 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts @@ -16,7 +16,7 @@ import type { UrlService } from '@n8n/backend-services'; import { AgentChangePublisher } from '../../agent-change-publisher.service'; import { AgentExecutionOrchestratorService } from '../../agent-execution-orchestrator.service'; -import type { Agent } from '../../entities/agent.entity'; +import type { ProjectAgent } from '../../entities/agent.entity'; import type { AgentChannelStatusReporter } from '../agent-channel-status-reporter'; import type { AgentRepository } from '../../repositories/agent.repository'; import { AgentChatBridge } from '../agent-chat-bridge'; @@ -88,7 +88,7 @@ class FakeIntegration extends AgentChatIntegration { } } -function makeAgent(overrides: Partial = {}): Agent { +function makeAgent(overrides: Partial = {}): ProjectAgent { return { id: 'agent-1', projectId: 'project-1', @@ -96,7 +96,7 @@ function makeAgent(overrides: Partial = {}): Agent { activeVersionId: null, activeVersion: null, ...overrides, - } as unknown as Agent; + } as unknown as ProjectAgent; } const slackIntegration: AgentIntegrationConfig = { diff --git a/packages/cli/src/modules/agents/integrations/__tests__/discord-integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/discord-integration.test.ts index c47c27451e0..af1c4ee438f 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/discord-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/discord-integration.test.ts @@ -5,7 +5,7 @@ import { mock } from 'vitest-mock-extended'; import { BadRequestError, ConflictError } from '@n8n/errors'; -import type { Agent } from '../../entities/agent.entity'; +import type { ProjectAgent } from '../../entities/agent.entity'; import type { AgentRepository } from '../../repositories/agent.repository'; import type { AgentChatIntegrationContext, @@ -350,7 +350,7 @@ describe('DiscordIntegration', () => { it('rejects repository ownership before calling Discord', async () => { agentRepository.findByIntegrationCredential.mockResolvedValue([ - { id: 'agent-other', name: 'Other Agent' } as Agent, + { id: 'agent-other', name: 'Other Agent' } as ProjectAgent, ]); const promise = integration.onBeforeConnect(connectionContext()); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts index 7c8cde3ed5c..f73b9c87c6f 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts @@ -9,7 +9,7 @@ import type { CacheService } from '@n8n/backend-services'; import type { UrlService } from '@n8n/backend-services'; import type { AgentIntegrationManagementService } from '../../agent-integration-management.service'; -import type { Agent } from '../../entities/agent.entity'; +import type { ProjectAgent } from '../../entities/agent.entity'; import type { AgentRepository } from '../../repositories/agent.repository'; import { SlackMethodsService } from '../platforms/slack/slack-methods.service'; @@ -42,7 +42,7 @@ describe('SlackMethodsService', () => { projectId: 'project-1', name: 'Support Agent', activeVersionId: 'version-1', - } as unknown as Agent; + } as unknown as ProjectAgent; const session = { projectId: 'project-1', diff --git a/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts index 8958dd0c1fe..ad7b1420a08 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts @@ -6,7 +6,7 @@ import { mock } from 'vitest-mock-extended'; import { ConflictError } from '@n8n/errors'; -import type { Agent } from '../../entities/agent.entity'; +import type { ProjectAgent } from '../../entities/agent.entity'; import type { AgentRepository } from '../../repositories/agent.repository'; import type { AgentChatIntegrationContext, @@ -201,7 +201,7 @@ describe('TeamsIntegration', () => { describe('onBeforeConnect', () => { it('rejects a credential another agent already connected', async () => { agentRepository.findByIntegrationCredential.mockResolvedValue([ - mock({ name: 'Support bot' }), + mock({ name: 'Support bot' }), ]); await expect(integration.onBeforeConnect(connectionContext())).rejects.toThrow(ConflictError); diff --git a/packages/cli/src/modules/agents/integrations/agent-channel-reconciler.service.ts b/packages/cli/src/modules/agents/integrations/agent-channel-reconciler.service.ts index d4b88922b83..3e324dc9875 100644 --- a/packages/cli/src/modules/agents/integrations/agent-channel-reconciler.service.ts +++ b/packages/cli/src/modules/agents/integrations/agent-channel-reconciler.service.ts @@ -15,7 +15,7 @@ import { agentChannelKey, agentChannelRef, type AgentChannelRef } from '../utils import { AgentChannelStatusReporter } from './agent-channel-status-reporter'; import { ChatIntegrationRegistry } from './agent-chat-integration'; import { ChatIntegrationService } from './chat-integration.service'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import type { AgentChannelStatus } from '../entities/agent-channel-status.entity'; import { AgentChannelStatusRepository } from '../repositories/agent-channel-status.repository'; import { AgentRepository } from '../repositories/agent.repository'; @@ -24,7 +24,7 @@ import { AgentRepository } from '../repositories/agent.repository'; export type ChannelReconcileReason = 'startup' | 'leader-takeover' | 'interval'; /** Channels a pass should see running, keyed by {@link agentChannelKey}. */ -type WantedChannels = Map; +type WantedChannels = Map; /** * How long shutdown waits for a pass to finish before withdrawing anyway. Well @@ -349,7 +349,7 @@ export class AgentChannelReconciler { } } - private collectWantedChannels(agents: Agent[]): WantedChannels { + private collectWantedChannels(agents: ProjectAgent[]): WantedChannels { const wanted: WantedChannels = new Map(); for (const agent of agents) { const integrations = (agent.integrations ?? []).filter( @@ -366,7 +366,7 @@ export class AgentChannelReconciler { } private async settleChannel( - agent: Agent, + agent: ProjectAgent, integration: AgentIntegrationConfig, own: AgentChannelStatus | undefined, reason: ChannelReconcileReason, @@ -391,7 +391,7 @@ export class AgentChannelReconciler { } private async startWantedChannel( - agent: Agent, + agent: ProjectAgent, integration: AgentIntegrationConfig, own: AgentChannelStatus | undefined, reason: ChannelReconcileReason, diff --git a/packages/cli/src/modules/agents/integrations/chat-integration.service.ts b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts index 8f7ab6a5988..383e5093ecc 100644 --- a/packages/cli/src/modules/agents/integrations/chat-integration.service.ts +++ b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts @@ -36,7 +36,7 @@ import { } from './leader-channel-relay.service'; import { channelIntegrationRecorder } from './recording/channel-integration-recorder'; import { recordAdapterCalls } from './recording/recording-adapter'; -import type { Agent } from '../entities/agent.entity'; +import { isProjectAgent, type ProjectAgent } from '../entities/agent.entity'; import { AgentChangePublisher } from '../agent-change-publisher.service'; import { agentChannelKey, @@ -594,7 +594,7 @@ export class ChatIntegrationService { * single bad credential doesn't block the others. */ async syncToConfig( - agent: Agent, + agent: ProjectAgent, previous: AgentIntegrationConfig[], next: AgentIntegrationConfig[], ): Promise { @@ -746,6 +746,7 @@ export class ChatIntegrationService { const ingressOwnedByLeader = this.shouldRouteToLeader(integration.type, true); if ( !agent || + !isProjectAgent(agent) || !persistedIntegration || (agent.activeVersionId !== null && !ingressOwnedByLeader) ) { @@ -827,7 +828,7 @@ export class ChatIntegrationService { * Which channels to start, and when to retry one that failed, is * {@link AgentChannelReconciler}'s decision. */ - async startChannel(agent: Agent, integration: AgentIntegrationConfig): Promise { + async startChannel(agent: ProjectAgent, integration: AgentIntegrationConfig): Promise { const skipExternalHooks = !this.instanceSettings.isLeader; await this.connect( agent.id, @@ -876,7 +877,7 @@ export class ChatIntegrationService { if (this.connections.has(key)) return; const agent = await this.agentRepository.findOne({ where: { id: agentId } }); - if (!agent) { + if (!agent || !isProjectAgent(agent)) { this.logger.warn( `[ChatIntegrationService] Cannot connect ${type} — agent ${agentId} not found`, ); @@ -1324,7 +1325,7 @@ export class ChatIntegrationService { private async initializeOutboundConnection( key: string, - agent: Agent, + agent: ProjectAgent, integration: AgentIntegrationConfig, ): Promise { const agentId = agent.id; @@ -1363,7 +1364,7 @@ export class ChatIntegrationService { // has to rebuild — same as a local connect does. Duplicate requests are // deduped by `runLeaderOperation`, not by inspecting the runtime. const agent = await this.agentRepository.findOne({ where: { id: agentId } }); - if (!agent) { + if (!agent || !isProjectAgent(agent)) { throw new UnexpectedError(`Agent ${agentId} not found on the leader instance`); } diff --git a/packages/cli/src/modules/agents/integrations/integrations-sync.ts b/packages/cli/src/modules/agents/integrations/integrations-sync.ts index 82ba80a1fbc..27b5863f95a 100644 --- a/packages/cli/src/modules/agents/integrations/integrations-sync.ts +++ b/packages/cli/src/modules/agents/integrations/integrations-sync.ts @@ -2,7 +2,7 @@ import { type AgentIntegrationConfig } from '@n8n/api-types'; import type { Logger } from '@n8n/backend-common'; import { Container } from '@n8n/di'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; /** * Reconcile chat platform connections after the builder writes a new @@ -16,7 +16,7 @@ import type { Agent } from '../entities/agent.entity'; * persistence and the chat integration runtime. */ export async function syncAgentIntegrations( - agent: Agent, + agent: ProjectAgent, previous: AgentIntegrationConfig[], next: AgentIntegrationConfig[], logger: Logger, diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts index 68a268656dd..4991dfb25cc 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts @@ -10,7 +10,7 @@ import type { InstanceSettings } from 'n8n-core'; import { BadRequestError, ConflictError } from '@n8n/errors'; import type { UrlService } from '@n8n/backend-services'; -import type { Agent } from '../../../../entities/agent.entity'; +import type { ProjectAgent } from '../../../../entities/agent.entity'; import type { AgentRepository } from '../../../../repositories/agent.repository'; import type { AgentChatIntegrationContext } from '../../../agent-chat-integration'; import { loadTelegramAdapter } from '../../../esm-loader'; @@ -29,7 +29,7 @@ const makeAgent = ( id: string, name: string, integrations: Array<{ type: string; credentialId: string }>, -) => ({ id, name, integrations }) as Agent; +) => ({ id, name, integrations }) as ProjectAgent; const makeContext = ( overrides: Partial = {}, diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts index 7d6520d1ad4..1a72ae699f5 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts @@ -32,7 +32,7 @@ import { SLACK_CREDENTIAL_TYPE, type SlackAppSetupSession, } from './slack-setup.types'; -import type { Agent } from '../../../entities/agent.entity'; +import type { ProjectAgent } from '../../../entities/agent.entity'; import { AgentRepository } from '../../../repositories/agent.repository'; import type { AgentIntegrationRemovalContext } from '../../agent-chat-integration'; import { stringProperty } from '../../integration-helpers'; @@ -376,7 +376,11 @@ export class SlackManagedSetupService { * that n8n built for another Agent, or a credential from any other Slack app, * returns false. */ - async isAppConfiguredForAgent(credentialId: string, agent: Agent, user: User): Promise { + async isAppConfiguredForAgent( + credentialId: string, + agent: ProjectAgent, + user: User, + ): Promise { const credential = await this.credentialsFinderService.findCredentialForUser( credentialId, user, @@ -395,7 +399,7 @@ export class SlackManagedSetupService { */ private async managedAppSendsEventsTo( data: ICredentialDataDecryptedObject, - agent: Agent, + agent: ProjectAgent, user: User, ): Promise { const managedAppId = stringProperty(data, 'managedAppId'); @@ -650,7 +654,7 @@ export class SlackManagedSetupService { private async getOrCreateManagedAppSession( options: InstallManagedSlackAppOptions, - agent: Agent, + agent: ProjectAgent, manager: ManagerCredentialContext, workspaceName: string, ): Promise<{ session: ManagedSlackAppSession; created: boolean }> { @@ -772,7 +776,7 @@ export class SlackManagedSetupService { return records; } - private async getInstallWorkspace(options: InstallManagedSlackAppOptions, agent: Agent) { + private async getInstallWorkspace(options: InstallManagedSlackAppOptions, agent: ProjectAgent) { const manager = await this.getManagerCredentialContext( options.managerCredentialId, options.projectId, @@ -792,7 +796,7 @@ export class SlackManagedSetupService { private async connectManagedInstallation( options: InstallManagedSlackAppOptions, - agent: Agent, + agent: ProjectAgent, session: ManagedSlackAppSession, response: Record, ): Promise { @@ -864,7 +868,7 @@ export class SlackManagedSetupService { private async createManagedAppSession( options: InstallManagedSlackAppOptions, - agent: Agent, + agent: ProjectAgent, manager: ManagerCredentialContext, workspaceName: string, key: string, diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts index 242aa123c58..ab259fc9f70 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts @@ -27,7 +27,7 @@ import { slackSetupCacheKey, } from './slack-setup.types'; import { AgentIntegrationManagementService } from '../../../agent-integration-management.service'; -import type { Agent } from '../../../entities/agent.entity'; +import type { ProjectAgent } from '../../../entities/agent.entity'; import { AgentRepository } from '../../../repositories/agent.repository'; import { stringProperty } from '../../integration-helpers'; @@ -104,7 +104,7 @@ export class SlackMethodsService { return new SlackApiError(action, stringProperty(response, 'error') ?? 'unknown_error'); } - async getAgent(agentId: string, projectId: string): Promise { + async getAgent(agentId: string, projectId: string): Promise { return await getAgentOrThrow(this.agentRepository, agentId, projectId); } @@ -200,7 +200,7 @@ export class SlackMethodsService { } async connectBotCredential( - agent: Agent, + agent: ProjectAgent, user: User, accessToken: string, session: SlackAppSetupSession, diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts index 59cd454a509..0679d575d98 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts @@ -10,7 +10,7 @@ import { JwtService, type UrlService } from '@n8n/backend-services'; import type { CredentialsService } from '@/credentials/credentials.service'; -import type { Agent } from '../../../../entities/agent.entity'; +import type { Agent, ProjectAgent } from '../../../../entities/agent.entity'; import { AgentCredentialLookupService } from '../../../agent-credential-lookup.service'; import type { AgentRepository } from '../../../../repositories/agent.repository'; import { TeamsArmTemplateService } from '../teams-arm-template.service'; @@ -45,7 +45,7 @@ describe('TeamsSetupService', () => { */ const agentWith = (integrations: Agent['integrations'], schema: Agent['schema'] = null) => Object.assign( - mock({ + mock({ id: AGENT_ID, projectId: PROJECT_ID, name: 'Support Bot', @@ -316,7 +316,7 @@ describe('TeamsSetupService', () => { describe('a credential another agent already uses', () => { const claimedByOtherAgent = () => { agentRepository.findByIntegrationCredentialAnyProject.mockResolvedValue([ - mock({ name: 'Sales Bot' }), + mock({ name: 'Sales Bot' }), ]); }; diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts index 834febc5749..09e9a8c8c4d 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts @@ -10,7 +10,7 @@ import { renderTeamsAppIcons } from './teams-app-icons'; import { TeamsArmTemplateService } from './teams-arm-template.service'; import { botResourceId } from './teams-bot-provisioning.service'; import { TeamsManifestService } from './teams-manifest.service'; -import type { Agent } from '../../../entities/agent.entity'; +import type { ProjectAgent } from '../../../entities/agent.entity'; import { AgentRepository } from '../../../repositories/agent.repository'; import { AgentCredentialLookupService } from '../../agent-credential-lookup.service'; import { stringProperty } from '../../integration-helpers'; @@ -202,7 +202,7 @@ export class TeamsSetupService { * The app's settings live on the connected integration, because they are what * the user chose for this agent's Teams app rather than for the agent itself. */ - private teamsSettingsOf(agent: Agent): AgentTeamsIntegrationSettings | undefined { + private teamsSettingsOf(agent: ProjectAgent): AgentTeamsIntegrationSettings | undefined { return agent.integrations?.find((item) => item.type === 'teams')?.settings; } @@ -255,7 +255,7 @@ export class TeamsSetupService { }; } - private async getAgent(scope: AgentScope): Promise { + private async getAgent(scope: AgentScope): Promise { const agent = await this.agentRepository.findByIdAndProjectId(scope.agentId, scope.projectId); if (!agent) throw new NotFoundError(`Agent "${scope.agentId}" not found`); return agent; @@ -266,7 +266,7 @@ export class TeamsSetupService { * the template route has none. The client ID this returns is not a secret: it * ships inside the manifest the user downloads. */ - private connectedCredentialId(agent: Agent): string | undefined { + private connectedCredentialId(agent: ProjectAgent): string | undefined { return agent.integrations?.find((item) => item.type === 'teams')?.credentialId; } @@ -312,7 +312,7 @@ export class TeamsSetupService { * handed over, for example after the agent was renamed. n8n sees the download * or the publish, not the upload in Teams, so this is as close as it gets. */ - private appOutdated(agent: Agent, identity: BotIdentity | null): boolean { + private appOutdated(agent: ProjectAgent, identity: BotIdentity | null): boolean { if (!identity?.teamsAppFingerprint) return false; const current = this.manifestService.buildManifest({ agentName: agent.name, diff --git a/packages/cli/src/modules/agents/register-favorite-resolver.ts b/packages/cli/src/modules/agents/register-favorite-resolver.ts index 5235332c2dd..0e0336557fb 100644 --- a/packages/cli/src/modules/agents/register-favorite-resolver.ts +++ b/packages/cli/src/modules/agents/register-favorite-resolver.ts @@ -14,6 +14,7 @@ export function registerFavoriteResolver() { { name, projectId }, ]), ), - exists: async (id) => await agentRepository.existsBy({ id }), + // Instance agents have no project, so they cannot be favorites. + exists: async (id) => (await agentRepository.getProjectIdById(id)) !== null, }); } diff --git a/packages/cli/src/modules/agents/repositories/agent-workflow-dependency.repository.ts b/packages/cli/src/modules/agents/repositories/agent-workflow-dependency.repository.ts index 78431c24cb0..497d3cb263f 100644 --- a/packages/cli/src/modules/agents/repositories/agent-workflow-dependency.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-workflow-dependency.repository.ts @@ -9,7 +9,7 @@ import { DataSource, In, type EntityManager } from '@n8n/typeorm'; import { AgentHistory } from '../entities/agent-history.entity'; import { AgentWorkflowDependency } from '../entities/agent-workflow-dependency.entity'; -import { Agent } from '../entities/agent.entity'; +import { Agent, isProjectAgent } from '../entities/agent.entity'; import { extractAgentWorkflowRefs } from '../utils/extract-agent-workflow-refs'; export type AgentWorkflowDependencyReference = Pick< @@ -43,7 +43,8 @@ export class AgentWorkflowDependencyRepository extends BaseRepository >; +/** Instance agents are code-defined and read-only, so project queries must not see them. */ +const projectScope = 'project' as const; +const projectScopeSql = "agent.scope = 'project'"; +/** The same filter for UPDATE builders, which have no `agent` alias. */ +const projectScopeUpdateSql = "scope = 'project'"; + /** Integration and publication state for channel runtime decisions. */ export type AgentIntegrationState = Pick; @@ -56,30 +67,35 @@ export class AgentRepository extends BaseRepository { return await this.existsBy({ id, revision }); } - async findByProjectId(projectId: string): Promise { - return await this.find({ - where: { projectId }, + async findByProjectId(projectId: string): Promise { + const agents = await this.find({ + where: { projectId, scope: projectScope }, relations: { activeVersion: true }, order: { updatedAt: 'DESC' }, }); + return agents.filter(isProjectAgent); } async findForExport( agentIds: string[], projectIds: string[] | null, options: { includeActiveVersion: boolean }, - ): Promise { + ): Promise { if (agentIds.length === 0 || projectIds?.length === 0) return []; // Keep both ID filters below the database parameter limit. const projectBatches = projectIds === null ? [null] : chunkIds([...new Set(projectIds)]); - const agents: Agent[] = []; + const agents: ProjectAgent[] = []; for (const ids of chunkIds([...new Set(agentIds)])) { for (const projects of projectBatches) { const found = await this.find({ - where: { id: In(ids), ...(projects === null ? {} : { projectId: In(projects) }) }, + where: { + id: In(ids), + scope: 'project', + ...(projects === null ? {} : { projectId: In(projects) }), + }, relations: { activeVersion: options.includeActiveVersion }, }); - agents.push(...found); + agents.push(...found.filter(isProjectAgent)); } } return agents.sort((left, right) => left.id.localeCompare(right.id)); @@ -123,10 +139,11 @@ export class AgentRepository extends BaseRepository { 'agent.availableInMCP', 'agent.updatedAt', ]) + .where(projectScopeSql) .orderBy('agent.updatedAt', 'DESC'); if (projectIds !== null) { - query.where('agent.projectId IN (:...projectIds)', { projectIds }); + query.andWhere('agent.projectId IN (:...projectIds)', { projectIds }); } if (options.query) { query.andWhere('LOWER(agent.name) LIKE LOWER(:query)', { query: `%${options.query}%` }); @@ -141,7 +158,7 @@ export class AgentRepository extends BaseRepository { query.take(options.limit); } - return await query.getMany(); + return (await query.getMany()).filter(isProjectAgent); } async findByProjectIdsPaginated( @@ -165,15 +182,16 @@ export class AgentRepository extends BaseRepository { query.leftJoinAndSelect('agent.project', 'project'); } + query.where(projectScopeSql); if (projectIds !== null) { - query.where('agent.projectId IN (:...projectIds)', { projectIds }); + query.andWhere('agent.projectId IN (:...projectIds)', { projectIds }); } this.applyFilters(query, options.filter); this.applySorting(query, options.sortBy, usageCounts); query.skip(options.skip).take(options.take); const [data, count] = await query.getManyAndCount(); - return { count, data }; + return { count, data: data.filter(isProjectAgent) }; } /** @@ -189,6 +207,7 @@ export class AgentRepository extends BaseRepository { query: SelectQueryBuilder, projectIds: string[] | null, ): SelectQueryBuilder { + query.andWhere(projectScopeSql); if (projectIds !== null) { query.andWhere('agent.projectId IN (:...projectIds)', { projectIds }); } @@ -215,7 +234,10 @@ export class AgentRepository extends BaseRepository { * project, when `projectIds` is null) and its published config carries the * channel. Loads `project` too — the chat page labels the agent with it. */ - async findChatReachableById(id: string, projectIds: string[] | null): Promise { + async findChatReachableById( + id: string, + projectIds: string[] | null, + ): Promise { if (projectIds?.length === 0) return null; const query = this.createQueryBuilder('agent') @@ -223,7 +245,7 @@ export class AgentRepository extends BaseRepository { .leftJoinAndSelect('agent.project', 'project') .where('agent.id = :id', { id }); - return await this.chatReachableQuery(query, projectIds).getOne(); + return projectAgentOrNull(await this.chatReachableQuery(query, projectIds).getOne()); } private applyFilters( @@ -329,11 +351,13 @@ export class AgentRepository extends BaseRepository { * published snapshot (or `null`) in a single query, which is what the publish button uses * to compute its state (published vs. unpublished, has changes vs. up to date). */ - async findByIdAndProjectId(id: string, projectId: string): Promise { - return await this.findOne({ - where: { id, projectId }, - relations: { activeVersion: true }, - }); + async findByIdAndProjectId(id: string, projectId: string): Promise { + return projectAgentOrNull( + await this.findOne({ + where: { id, projectId, scope: projectScope }, + relations: { activeVersion: true }, + }), + ); } async isN8nChatPublished(id: string, projectId: string): Promise { @@ -348,13 +372,37 @@ export class AgentRepository extends BaseRepository { /** * Finds an agent by ID alone. Agent IDs are globally unique, so this is safe * for callers whose access check does not hinge on a specific project (e.g. - * users with global agent scopes). + * users with global agent scopes). Returns no instance agent. */ - async findById(id: string): Promise { - return await this.findOne({ - where: { id }, - relations: { activeVersion: true }, - }); + async findById(id: string): Promise { + return projectAgentOrNull( + await this.findOne({ + where: { id, scope: projectScope }, + relations: { activeVersion: true }, + }), + ); + } + + /** + * Creates or renames the row of a code-defined instance agent. The row + * anchors threads, executions and queue items through foreign keys. It + * stores no config, because the runtime comes from code. + */ + async ensureInstanceAgent(id: string, name: string): Promise { + // Two mains can start at the same time: the second insert of the id must not fail. + await this.createQueryBuilder() + .insert() + .into(Agent) + .values({ id, name, scope: 'instance', projectId: null, schema: null }) + .orIgnore() + .execute(); + const existing = await this.findOneOrFail({ select: ['id', 'name', 'scope'], where: { id } }); + if (existing.scope !== 'instance') { + throw new UnexpectedError(`Agent "${id}" exists as a project agent`); + } + if (existing.name !== name) { + await this.update({ id, scope: 'instance' }, { name }); + } } async findDependencyIndexAgentIdsBatch( @@ -363,11 +411,12 @@ export class AgentRepository extends BaseRepository { ): Promise>> { const query = this.createQueryBuilder('agent') .select(['agent.id']) + .where(projectScopeSql) .orderBy('agent.id', 'ASC') .take(batchSize); if (afterId !== null) { - query.where('agent.id > :afterId', { afterId }); + query.andWhere('agent.id > :afterId', { afterId }); } return await query.getMany(); @@ -375,44 +424,49 @@ export class AgentRepository extends BaseRepository { async findSummariesByIds( ids: string[], - ): Promise>> { + ): Promise>>> { if (ids.length === 0) return []; - return await this.find({ + const agents = await this.find({ select: ['id', 'name', 'projectId'], - where: { id: In(ids) }, + where: { id: In(ids), scope: projectScope }, }); + return agents.filter(isProjectAgent); } - async findByIdInProjects(id: string, projectIds: string[]): Promise { + async findByIdInProjects(id: string, projectIds: string[]): Promise { if (projectIds.length === 0) return null; - return await this.findOne({ - where: { id, projectId: In(projectIds) }, - relations: { activeVersion: true }, - }); + return projectAgentOrNull( + await this.findOne({ + where: { id, projectId: In(projectIds), scope: projectScope }, + relations: { activeVersion: true }, + }), + ); } /** Ownership check only — skips `findByIdAndProjectId`'s `activeVersion` load. */ async existsByIdAndProjectId(id: string, projectId: string): Promise { - return await this.exists({ where: { id, projectId } }); + return await this.exists({ where: { id, projectId, scope: projectScope } }); } /** Lightweight project-id lookup — avoids loading the full agent config. */ async getProjectIdById(id: string): Promise { const result = await this.findOne({ select: ['projectId'], - where: { id }, + where: { id, scope: projectScope }, }); return result?.projectId ?? null; } /** Name and home project for the budget-alert email. Skips the config JSON. */ - async findBudgetAlertTarget(id: string): Promise | null> { + async findBudgetAlertTarget( + id: string, + ): Promise> | null> { const agent = await this.findOne({ select: ['name', 'projectId'], - where: { id }, + where: { id, scope: projectScope }, }); - if (!agent) return null; + if (!agent || !isProjectAgent(agent)) return null; return { name: agent.name, projectId: agent.projectId }; } @@ -423,32 +477,33 @@ export class AgentRepository extends BaseRepository { if (ids.length === 0) return []; return await this.find({ select: ['id', 'name', 'activeVersionId'], - where: { id: In(ids), projectId }, + where: { id: In(ids), projectId, scope: projectScope }, }); } async findMcpAvailabilityCandidates( where: { ids: string[] } | { projectIds: string[] } | { all: true }, - ): Promise>> { + ): Promise>>> { if ('ids' in where && where.ids.length === 0) return []; if ('projectIds' in where && where.projectIds.length === 0) return []; const criteria = 'ids' in where - ? { id: In(where.ids) } + ? { id: In(where.ids), scope: projectScope } : 'projectIds' in where - ? { projectId: In(where.projectIds) } - : undefined; + ? { projectId: In(where.projectIds), scope: projectScope } + : { scope: projectScope }; - return await this.find({ + const agents = await this.find({ select: ['id', 'projectId', 'availableInMCP'], where: criteria, }); + return agents.filter(isProjectAgent); } async setAvailableInMCP(agentIds: string[], availableInMCP: boolean): Promise { if (agentIds.length === 0) return; - await this.update({ id: In(agentIds) }, { availableInMCP }); + await this.update({ id: In(agentIds), scope: projectScope }, { availableInMCP }); } /** @@ -458,7 +513,7 @@ export class AgentRepository extends BaseRepository { */ async claimSetupCompleted(id: string, completedAt: Date): Promise { const result = await this.update( - { id, setupCompletedAt: IsNull() }, + { id, scope: projectScope, setupCompletedAt: IsNull() }, { setupCompletedAt: completedAt }, ); @@ -469,7 +524,7 @@ export class AgentRepository extends BaseRepository { async findIntegrationState(id: string): Promise { return await this.findOne({ select: ['integrations', 'versionId', 'activeVersionId'], - where: { id }, + where: { id, scope: projectScope }, }); } @@ -487,6 +542,7 @@ export class AgentRepository extends BaseRepository { const result = await this.update( { id, + scope: projectScope, revision: expected.revision, versionId: expected.versionId ?? IsNull(), activeVersionId: expected.activeVersionId ?? IsNull(), @@ -497,16 +553,18 @@ export class AgentRepository extends BaseRepository { return (result.affected ?? 0) > 0; } - async findPublished(): Promise { - return await this.createQueryBuilder('agent') + async findPublished(): Promise { + const agents = await this.createQueryBuilder('agent') .innerJoinAndSelect('agent.activeVersion', 'activeVersion') + .where(projectScopeSql) .getMany(); + return agents.filter(isProjectAgent); } /** The ids of all agents with a published version. Loads no version rows. */ async findPublishedAgentIds(): Promise { const rows = await this.find({ - where: { activeVersionId: Not(IsNull()) }, + where: { activeVersionId: Not(IsNull()), scope: projectScope }, select: ['id'], }); return rows.map((row) => row.id); @@ -519,7 +577,7 @@ export class AgentRepository extends BaseRepository { */ async findActiveVersionId(agentId: string): Promise { const row = await this.findOne({ - where: { id: agentId }, + where: { id: agentId, scope: projectScope }, select: ['id', 'activeVersionId'], }); return row?.activeVersionId ?? null; @@ -530,7 +588,7 @@ export class AgentRepository extends BaseRepository { if (agentIds.length === 0) return new Set(); const rows = await this.find({ - where: { id: In(agentIds), activeVersionId: Not(IsNull()) }, + where: { id: In(agentIds), activeVersionId: Not(IsNull()), scope: projectScope }, select: ['id'], }); return new Set(rows.map((row) => row.id)); @@ -553,7 +611,10 @@ export class AgentRepository extends BaseRepository { credentialId: string, excludeAgentId: string, ): Promise>> { - const agents = await this.find({ select: ['id', 'name', 'integrations'] }); + const agents = await this.find({ + select: ['id', 'name', 'integrations'], + where: { scope: projectScope }, + }); return agents.filter( (agent) => agent.id !== excludeAgentId && @@ -593,13 +654,17 @@ export class AgentRepository extends BaseRepository { credentialId: string, projectId: string, excludeAgentId: string, - ): Promise { - const agents = await this.find({ where: { projectId } }); - return agents.filter( - (agent) => - agent.id !== excludeAgentId && - (agent.integrations ?? []).some((i) => i.type === type && i.credentialId === credentialId), - ); + ): Promise { + const agents = await this.find({ where: { projectId, scope: projectScope } }); + return agents + .filter(isProjectAgent) + .filter( + (agent) => + agent.id !== excludeAgentId && + (agent.integrations ?? []).some( + (i) => i.type === type && i.credentialId === credentialId, + ), + ); } /** @@ -626,6 +691,7 @@ export class AgentRepository extends BaseRepository { revision: () => 'revision + 1', }) .where('id = :id AND revision = :expected', { id, expected: expectedRevision }) + .andWhere(projectScopeUpdateSql) .execute(); return (result.affected ?? 0) > 0; } @@ -658,6 +724,7 @@ export class AgentRepository extends BaseRepository { revision: () => 'revision + 1', }) .where('id = :id AND revision = :expected', { id: agent.id, expected: expectedRevision }) + .andWhere(projectScopeUpdateSql) .execute(); const won = (result.affected ?? 0) > 0; if (won) { @@ -687,6 +754,7 @@ export class AgentRepository extends BaseRepository { .set({ versionId: randomUUID() }) .where('id = :id', { id }) .andWhere('"versionId" = "activeVersionId"') + .andWhere(projectScopeUpdateSql) .execute(); if ((result.affected ?? 0) > 0) changed.push(id); } @@ -748,7 +816,7 @@ export class AgentRepository extends BaseRepository { async findProjectIdsByIds( ids: string[], ctx: OperationContext = {}, - ): Promise>> { + ): Promise>>> { const agents: Array> = []; // In batches: a skill can have more followers than SQLite allows bound parameters. for (const chunk of chunkIds([...new Set(ids)])) { @@ -759,7 +827,7 @@ export class AgentRepository extends BaseRepository { })), ); } - return agents; + return agents.filter(isProjectAgent); } /** @@ -779,6 +847,7 @@ export class AgentRepository extends BaseRepository { .update(Agent) .set({ schema: agent.schema, skills: agent.skills, updatedAt: agent.updatedAt }) .where('id = :id', { id: agent.id }) + .andWhere(projectScopeUpdateSql) .execute(); } @@ -791,6 +860,11 @@ export class AgentRepository extends BaseRepository { .update(Agent) .set({ schema: agent.schema, updatedAt: agent.updatedAt }) .where('id = :id', { id: agent.id }) + .andWhere(projectScopeUpdateSql) .execute(); } } + +function projectAgentOrNull(agent: Agent | null): ProjectAgent | null { + return agent && isProjectAgent(agent) ? agent : null; +} diff --git a/packages/cli/src/modules/agents/repositories/skill.repository.ts b/packages/cli/src/modules/agents/repositories/skill.repository.ts index 2e31221e853..d0a01545aa9 100644 --- a/packages/cli/src/modules/agents/repositories/skill.repository.ts +++ b/packages/cli/src/modules/agents/repositories/skill.repository.ts @@ -16,7 +16,7 @@ import { randomUUID } from 'node:crypto'; import { AgentVersionSkillDependency } from '../entities/agent-version-skill-dependency.entity'; import { AgentHistory } from '../entities/agent-history.entity'; import { AgentSkillDependency } from '../entities/agent-skill-dependency.entity'; -import { Agent } from '../entities/agent.entity'; +import { Agent, isProjectAgent, type ProjectScoped } from '../entities/agent.entity'; import { SkillFile } from '../entities/skill-file.entity'; import { SkillVersion } from '../entities/skill-version.entity'; import { Skill, type SkillSource } from '../entities/skill.entity'; @@ -448,7 +448,8 @@ export class SkillRepository extends BaseRepository { .innerJoin(AgentSkillDependency, 'dep', 'dep.agentId = agent.id') .where('dep.skillId = :skillId', { skillId }) .select(['agent.id', 'agent.name', 'agent.projectId']) - .getMany(); + .getMany() + .then((agents) => agents.filter(isProjectAgent)); const published = await manager .createQueryBuilder(Agent, 'agent') .where((qb) => { @@ -463,9 +464,10 @@ export class SkillRepository extends BaseRepository { }) .setParameter('skillId', skillId) .select(['agent.id', 'agent.name', 'agent.projectId', 'agent.activeVersionId']) - .getMany(); + .getMany() + .then((agents) => agents.filter(isProjectAgent)); - const isVisible = (agent: Pick) => + const isVisible = (agent: ProjectScoped>) => projectIds === 'all' || projectIds.includes(agent.projectId); const hidden = new Set( [...drafted, ...published].filter((agent) => !isVisible(agent)).map((agent) => agent.id), @@ -695,7 +697,7 @@ export class SkillRepository extends BaseRepository { async findAgents( agentIds: string[], ctx: OperationContext = {}, - ): Promise>> { + ): Promise>>> { const manager = this.managerFor(ctx); const agents = await inChunks( agentIds, @@ -705,7 +707,9 @@ export class SkillRepository extends BaseRepository { select: ['id', 'name', 'projectId'], }), ); - return new Map(agents.map(({ id, name, projectId }) => [id, { name, projectId }])); + return new Map( + agents.filter(isProjectAgent).map(({ id, name, projectId }) => [id, { name, projectId }]), + ); } /** diff --git a/packages/cli/src/modules/agents/skills/skill.service.ts b/packages/cli/src/modules/agents/skills/skill.service.ts index 3c1257ccd64..38b3f52d9c0 100644 --- a/packages/cli/src/modules/agents/skills/skill.service.ts +++ b/packages/cli/src/modules/agents/skills/skill.service.ts @@ -436,7 +436,7 @@ export class SkillService { /** An agent can attach instance skills, the skills of its own project and its own agent skills. */ isAttachable( skill: Pick, - agent: { id: string; projectId: string }, + agent: { id: string; projectId: string | null }, ): boolean { if (skill.agentId !== null) return skill.agentId === agent.id; return skill.projectId === null || skill.projectId === agent.projectId; @@ -449,7 +449,7 @@ export class SkillService { * must be attachable and must not clash by name. */ async checkRefs( - agent: { id: string; projectId: string }, + agent: { id: string; projectId: string | null }, refs: SkillRef[], ctx: OperationContext = {}, ): Promise { diff --git a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-cleanup.service.test.ts b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-cleanup.service.test.ts index 311907fdb9b..601cbd18b9f 100644 --- a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-cleanup.service.test.ts +++ b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-cleanup.service.test.ts @@ -3,7 +3,7 @@ import type { AgentJsonConfig } from '@n8n/api-types'; import { mock } from 'vitest-mock-extended'; import type { AgentRuntimeCacheService } from '../../agent-runtime-cache.service'; -import type { Agent } from '../../entities/agent.entity'; +import type { ProjectAgent } from '../../entities/agent.entity'; import type { AgentRepository } from '../../repositories/agent.repository'; import { SubAgentCleanupService } from '../sub-agent-cleanup.service'; import { type AgentFixtureOverrides } from '../../__tests__/test-utils/stored-agent-config'; @@ -11,7 +11,7 @@ import { type AgentFixtureOverrides } from '../../__tests__/test-utils/stored-ag const projectId = 'project-1'; const childAgentId = 'child-1'; -function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { +function makeAgent(overrides: AgentFixtureOverrides = {}): ProjectAgent { return { id: 'parent-1', projectId, @@ -21,7 +21,7 @@ function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { instructions: 'Delegate work', } as AgentJsonConfig, ...overrides, - } as unknown as Agent; + } as unknown as ProjectAgent; } function makeService() { diff --git a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-source-resolver.test.ts b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-source-resolver.test.ts index f1a16ffcb8d..e1f9e1d47be 100644 --- a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-source-resolver.test.ts +++ b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-source-resolver.test.ts @@ -5,7 +5,7 @@ import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; import type { AgentHistory } from '../../entities/agent-history.entity'; -import type { Agent } from '../../entities/agent.entity'; +import type { ProjectAgent } from '../../entities/agent.entity'; import type { AgentHistoryRepository } from '../../repositories/agent-history.repository'; import type { AgentRepository } from '../../repositories/agent.repository'; import { SubAgentSourceResolver } from '../sub-agent-source-resolver'; @@ -57,7 +57,7 @@ function makeAgentHistory(overrides: AgentHistoryFixtureOverrides = {}): AgentHi } as unknown as AgentHistory; } -function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { +function makeAgent(overrides: AgentFixtureOverrides = {}): ProjectAgent { return { id: agentId, name: 'Helper Agent', @@ -69,7 +69,7 @@ function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { activeVersionId: versionId, activeVersion: makeAgentHistory(), ...overrides, - } as unknown as Agent; + } as unknown as ProjectAgent; } describe('SubAgentSourceResolver', () => { diff --git a/packages/cli/src/modules/agents/utils/agent-published-snapshot.ts b/packages/cli/src/modules/agents/utils/agent-published-snapshot.ts index 01899e826ae..1b881dca7dd 100644 --- a/packages/cli/src/modules/agents/utils/agent-published-snapshot.ts +++ b/packages/cli/src/modules/agents/utils/agent-published-snapshot.ts @@ -4,13 +4,13 @@ import { OperationalError } from 'n8n-workflow'; import type { AgentSkillRefsService } from '../agent-skill-refs.service'; import type { AgentHistory } from '../entities/agent-history.entity'; -import type { Agent } from '../entities/agent.entity'; +import type { Agent, ProjectAgent } from '../entities/agent.entity'; import type { AgentSkillRefs } from '../json-config/agent-document'; /** The agent content that a run uses: the draft or the published version. */ -export interface AgentRunSource { +export interface AgentRunSource { /** The agent draft, or the agent with the content of its published version. */ - agent: Agent; + agent: T; skillRefs: AgentSkillRefs; /** Skill bodies from the skills hub, keyed by skill id: the draft's or the pinned ones. */ skills: Record; @@ -26,25 +26,25 @@ function getPublishedVersion(agentEntity: Agent): AgentHistory { return activeVersion; } -function getPublishedAgentSnapshot(agentEntity: Agent): Agent { +function getPublishedAgentSnapshot(agentEntity: T): T { const activeVersion = getPublishedVersion(agentEntity); return { ...agentEntity, schema: activeVersion.schema, tools: activeVersion.tools ?? {}, - } as Agent; + }; } /** * Select the draft or the published version of an agent for a run, with the * skill refs of the same draft or version. */ -export async function resolveAgentRunSource( - agentEntity: Agent, +export async function resolveAgentRunSource( + agentEntity: T, usePublishedVersion: boolean, agentSkillRefs: AgentSkillRefsService, ctx: OperationContext, -): Promise { +): Promise> { if (!usePublishedVersion) { return { agent: agentEntity, ...(await agentSkillRefs.readDraft(agentEntity, ctx)) }; } diff --git a/packages/cli/src/modules/agents/utils/get-agent-or-throw.ts b/packages/cli/src/modules/agents/utils/get-agent-or-throw.ts index 01caa6d3988..a6bf06bda89 100644 --- a/packages/cli/src/modules/agents/utils/get-agent-or-throw.ts +++ b/packages/cli/src/modules/agents/utils/get-agent-or-throw.ts @@ -1,6 +1,6 @@ import { NotFoundError } from '@n8n/errors'; -import type { Agent } from '../entities/agent.entity'; +import type { ProjectAgent } from '../entities/agent.entity'; import type { AgentRepository } from '../repositories/agent.repository'; export async function getAgentOrThrow( @@ -8,7 +8,7 @@ export async function getAgentOrThrow( agentId: string, projectId: string, message = `Agent "${agentId}" not found`, -): Promise { +): Promise { const agent = await repository.findByIdAndProjectId(agentId, projectId); if (!agent) throw new NotFoundError(message); return agent; diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-pending-agent.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-pending-agent.service.test.ts index 3cdccc83392..e3200af8111 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-pending-agent.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-pending-agent.service.test.ts @@ -8,7 +8,7 @@ import { ForbiddenError, NotFoundError } from '@n8n/errors'; import { AgentDefaultModelResolverService } from '@/modules/agents/agent-default-model-resolver.service'; import { AgentRunnableStateService } from '@/modules/agents/agent-runnable-state.service'; import { AgentsService } from '@/modules/agents/agents.service'; -import type { Agent } from '@/modules/agents/entities/agent.entity'; +import type { ProjectAgent } from '@/modules/agents/entities/agent.entity'; import * as checkAccess from '@/permissions.ee/check-access'; import type { InstanceAiMemoryService } from '../instance-ai-memory.service'; @@ -78,7 +78,7 @@ describe('InstanceAiPendingAgentService', () => { const { service, memoryService, agentsService } = setup(); memoryService.getThreadMetadata.mockResolvedValue(pendingMetadata); agentsService.create.mockResolvedValue( - mock({ id: AGENT_ID, name: 'New Agent', projectId: PROJECT_ID }), + mock({ id: AGENT_ID, name: 'New Agent', projectId: PROJECT_ID }), ); const result = await service.persistAndBind(user, THREAD_ID, payload); @@ -101,7 +101,7 @@ describe('InstanceAiPendingAgentService', () => { const { service, memoryService, agentsService, defaultModelResolver } = setup(); memoryService.getThreadMetadata.mockResolvedValue(pendingMetadata); defaultModelResolver.resolve.mockResolvedValue({ model: 'anthropic/x', credential: 'cred-1' }); - agentsService.create.mockResolvedValue(mock({ id: AGENT_ID, name: 'New Agent' })); + agentsService.create.mockResolvedValue(mock({ id: AGENT_ID, name: 'New Agent' })); await service.persistAndBind(user, THREAD_ID, payload); @@ -117,7 +117,7 @@ describe('InstanceAiPendingAgentService', () => { it('adopts an existing row without resolving a default model for it', async () => { const { service, memoryService, agentsService, defaultModelResolver } = setup(); memoryService.getThreadMetadata.mockResolvedValue(pendingMetadata); - const existing = mock({ id: AGENT_ID, name: 'Support Triage' }); + const existing = mock({ id: AGENT_ID, name: 'Support Triage' }); agentsService.findById.mockResolvedValue(existing); const result = await service.persistAndBind(user, THREAD_ID, payload); @@ -134,7 +134,7 @@ describe('InstanceAiPendingAgentService', () => { const { service, memoryService, agentsService } = setup(); memoryService.getThreadMetadata.mockResolvedValue(pendingMetadata); agentsService.create.mockResolvedValue( - mock({ id: AGENT_ID, name: 'Support Triage', projectId: PROJECT_ID }), + mock({ id: AGENT_ID, name: 'Support Triage', projectId: PROJECT_ID }), ); await service.persistAndBind(user, THREAD_ID, payload); @@ -151,7 +151,9 @@ describe('InstanceAiPendingAgentService', () => { it('accepts the active bound target as proof once the pending marker is gone', async () => { const { service, memoryService, agentsService } = setup(); memoryService.getThreadMetadata.mockResolvedValue(boundMetadata); - agentsService.create.mockResolvedValue(mock({ id: AGENT_ID, name: 'Support Triage' })); + agentsService.create.mockResolvedValue( + mock({ id: AGENT_ID, name: 'Support Triage' }), + ); await expect(service.persistAndBind(user, THREAD_ID, payload)).resolves.toBeDefined(); }); @@ -176,7 +178,7 @@ describe('InstanceAiPendingAgentService', () => { it.each(unattestedMetadata)('refuses to adopt an existing row with %s', async (_l, metadata) => { const { service, memoryService, agentsService } = setup(); memoryService.getThreadMetadata.mockResolvedValue(metadata); - agentsService.findById.mockResolvedValue(mock({ id: AGENT_ID })); + agentsService.findById.mockResolvedValue(mock({ id: AGENT_ID })); await expect(service.persistAndBind(user, THREAD_ID, payload)).rejects.toThrow(ForbiddenError); expect(agentsService.create).not.toHaveBeenCalled(); @@ -189,7 +191,7 @@ describe('InstanceAiPendingAgentService', () => { it.each(unattestedMetadata)('still creates the draft with %s', async (_l, metadata) => { const { service, memoryService, agentsService } = setup(); memoryService.getThreadMetadata.mockResolvedValue(metadata); - agentsService.create.mockResolvedValue(mock({ id: AGENT_ID, name: 'New Agent' })); + agentsService.create.mockResolvedValue(mock({ id: AGENT_ID, name: 'New Agent' })); await expect(service.persistAndBind(user, THREAD_ID, payload)).resolves.toBeDefined(); // Strict: an unattested create must not quietly adopt on a collision. @@ -204,7 +206,7 @@ describe('InstanceAiPendingAgentService', () => { it('needs only agent:create to create, not agent:update', async () => { const { service, memoryService, agentsService } = setup(); memoryService.getThreadMetadata.mockResolvedValue({}); - agentsService.create.mockResolvedValue(mock({ id: AGENT_ID, name: 'New Agent' })); + agentsService.create.mockResolvedValue(mock({ id: AGENT_ID, name: 'New Agent' })); const userHasScopes = vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true); await service.persistAndBind(user, THREAD_ID, payload); @@ -231,7 +233,7 @@ describe('InstanceAiPendingAgentService', () => { it('propagates a binding failure without swallowing it', async () => { const { service, memoryService, agentsService } = setup(); memoryService.getThreadMetadata.mockResolvedValue(pendingMetadata); - agentsService.create.mockResolvedValue(mock({ id: AGENT_ID, name: 'New Agent' })); + agentsService.create.mockResolvedValue(mock({ id: AGENT_ID, name: 'New Agent' })); memoryService.bindAgentBuilderTarget.mockRejectedValue(new Error('thread write failed')); await expect(service.persistAndBind(user, THREAD_ID, payload)).rejects.toThrow( @@ -255,7 +257,7 @@ describe('InstanceAiPendingAgentService', () => { const { service, memoryService, agentsService } = setup(); memoryService.getThreadProjectId.mockResolvedValue(undefined); memoryService.getThreadMetadata.mockResolvedValue(pendingMetadata); - agentsService.create.mockResolvedValue(mock({ id: AGENT_ID, name: 'New Agent' })); + agentsService.create.mockResolvedValue(mock({ id: AGENT_ID, name: 'New Agent' })); await expect(service.persistAndBind(user, THREAD_ID, payload)).resolves.toBeDefined(); }); diff --git a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts index bf0aad8aaac..1af8bdfc150 100644 --- a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts @@ -53,7 +53,7 @@ import { import { AgentValidationService } from '@/modules/agents/agent-validation.service'; import { AgentsService } from '@/modules/agents/agents.service'; import { AttachableWorkflowsService } from '@/modules/agents/attachable-workflows.service'; -import type { Agent } from '@/modules/agents/entities/agent.entity'; +import type { ProjectAgent } from '@/modules/agents/entities/agent.entity'; import { SlackManagedSetupService } from '@/modules/agents/integrations/platforms/slack/slack-managed-setup.service'; import { SlackManualSetupService } from '@/modules/agents/integrations/platforms/slack/slack-manual-setup.service'; import type { NodeToolAiGatewayService } from '@/modules/agents/json-config/node-tool-ai-gateway.service'; @@ -119,7 +119,7 @@ const approvalSuspension = ( resumeSchema: standardApprovalResumeSchema, }); -const agentEntity = (overrides: Record = {}): Agent => +const agentEntity = (overrides: Record = {}): ProjectAgent => ({ id: 'agent-1', name: 'My Agent', @@ -132,7 +132,7 @@ const agentEntity = (overrides: Record = {}): Agent => schema: baseConfig, integrations: [], ...overrides, - }) as unknown as Agent; + }) as unknown as ProjectAgent; describe('McpAgentToolsService', () => { // One fake skill refs seam for the MCP service and the services it writes through. @@ -226,7 +226,7 @@ describe('McpAgentToolsService', () => { return await tool.handler(input, { signal }); }; - const useRealCustomToolPersistence = (agent: Agent) => { + const useRealCustomToolPersistence = (agent: ProjectAgent) => { const agentRepository = mock(); const runtimeCacheService = mock(); const lifecycleTelemetry = mock(); diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-slack-setup.ts b/packages/cli/src/modules/mcp/tools/agents/agent-slack-setup.ts index 76d509d37c6..1d651b5edd5 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-slack-setup.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-slack-setup.ts @@ -5,7 +5,7 @@ import { Service } from '@n8n/di'; import { SlackManagedSetupService } from '@/modules/agents/integrations/platforms/slack/slack-managed-setup.service'; import { SlackManualSetupService } from '@/modules/agents/integrations/platforms/slack/slack-manual-setup.service'; -import type { Agent } from '@/modules/agents/entities/agent.entity'; +import type { ProjectAgent } from '@/modules/agents/entities/agent.entity'; export const SLACK_INTEGRATION_TYPE = 'slack'; @@ -40,7 +40,7 @@ export class McpAgentSlackSetup { * client must come back with an explicit workspace before n8n creates a * Slack app. */ - async describeSetup(agent: Agent, user: User, agentUrl: string) { + async describeSetup(agent: ProjectAgent, user: User, agentUrl: string) { const state = await this.managedSetup.getSetupState({ projectId: agent.projectId, agentId: agent.id, @@ -112,7 +112,7 @@ export class McpAgentSlackSetup { } async install( - agent: Agent, + agent: ProjectAgent, user: User, target: { managerCredentialId: string; workspaceId: string }, ) { @@ -131,7 +131,7 @@ export class McpAgentSlackSetup { * credential that n8n did not build for this Agent, return what the app must * be set to. */ - async describeBotCredential(agent: Agent, user: User, credentialId: string) { + async describeBotCredential(agent: ProjectAgent, user: User, credentialId: string) { if (await this.isAppConfiguredForAgent(agent, user, credentialId)) { return { slackApp: { configuredForAgent: true } }; } @@ -145,7 +145,7 @@ export class McpAgentSlackSetup { * A failed check counts as unverified: the caller has already saved the * connect, or can only report the result. */ - async isAppConfiguredForAgent(agent: Agent, user: User, credentialId: string) { + async isAppConfiguredForAgent(agent: ProjectAgent, user: User, credentialId: string) { try { return await this.managedSetup.isAppConfiguredForAgent(credentialId, agent, user); } catch (error) { @@ -158,7 +158,7 @@ export class McpAgentSlackSetup { } } - private async appRequirements(agent: Agent) { + private async appRequirements(agent: ProjectAgent) { const { manifest } = await this.manualSetup.getManifest({ projectId: agent.projectId, agentId: agent.id, 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 2188f9763a5..0910b785114 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 @@ -52,7 +52,7 @@ import { import { AgentValidationService } from '@/modules/agents/agent-validation.service'; import { AgentsService } from '@/modules/agents/agents.service'; import { AttachableWorkflowsService } from '@/modules/agents/attachable-workflows.service'; -import type { Agent } from '@/modules/agents/entities/agent.entity'; +import type { ProjectAgent } from '@/modules/agents/entities/agent.entity'; import { composeJsonConfig } from '@/modules/agents/json-config/agent-config-composition'; import { listMcpServerTools } from '@/modules/agents/json-config/mcp-client-factory'; import { sanitizeUnknownAgentCredentials } from '@/modules/agents/json-config/sanitize-unknown-agent-credentials'; @@ -1127,7 +1127,7 @@ export class McpAgentToolsService { }; } - private async getAgentSnapshot(user: User, agent: Agent) { + private async getAgentSnapshot(user: User, agent: ProjectAgent) { const { id: agentId, projectId } = agent; const config = await this.configFromEntity(agent); const credentialProvider = this.credentialProvider(user, projectId); @@ -1231,7 +1231,7 @@ export class McpAgentToolsService { return getAgentConfigHash(await this.agentConfigService.getConfig(agentId, projectId)); } - private async configFromEntity(agent: Agent): Promise { + private async configFromEntity(agent: ProjectAgent): Promise { const config = composeJsonConfig(agent, await this.agentSkillRefs.refsForDraft(agent, {})); if (!config) throw new UserError('Agent has no JSON config yet.'); return config; @@ -1243,7 +1243,7 @@ export class McpAgentToolsService { private async callAgent( user: User, - agent: Agent, + agent: ProjectAgent, request: CallAgentInput['request'], abortSignal?: AbortSignal, ): Promise> { @@ -1642,7 +1642,7 @@ export class McpAgentToolsService { * the node-tool JSON-Schema checks from `validateConfig`, so validate_agent * and publish_agent cannot drift from the canonical validator. */ - private async validateAgent(user: User, agent: Agent) { + private async validateAgent(user: User, agent: ProjectAgent) { const projectId = agent.projectId; const config = await this.configFromEntity(agent); const credentialProvider = this.credentialProvider(user, projectId); @@ -1808,7 +1808,11 @@ export class McpAgentToolsService { : await this.connectIntegration(user, input, agent); } - private async disconnectIntegration(user: User, input: UpdateIntegrationInput, agent: Agent) { + private async disconnectIntegration( + user: User, + input: UpdateIntegrationInput, + agent: ProjectAgent, + ) { const { credentialId } = input; if (!credentialId) throw new UserError('credentialId is required to disconnect'); if (input.managerCredentialId || input.workspaceId) { @@ -1833,7 +1837,7 @@ export class McpAgentToolsService { }; } - private async connectIntegration(user: User, input: UpdateIntegrationInput, agent: Agent) { + private async connectIntegration(user: User, input: UpdateIntegrationInput, agent: ProjectAgent) { const managedTarget = this.managedSlackTarget(input); if (managedTarget) return await this.installManagedSlackApp(user, agent, managedTarget); if (!input.credentialId) { @@ -1878,7 +1882,7 @@ export class McpAgentToolsService { private async installManagedSlackApp( user: User, - agent: Agent, + agent: ProjectAgent, target: { managerCredentialId: string; workspaceId: string }, ) { // Managed setup stores the new bot token as a credential in the project. @@ -1943,7 +1947,7 @@ export class McpAgentToolsService { private async connectWithCredential( user: User, input: UpdateIntegrationInput, - agent: Agent, + agent: ProjectAgent, credentialId: string, ) { const candidate = { @@ -2000,7 +2004,7 @@ export class McpAgentToolsService { * `search_agents` intentionally still sees every accessible agent so * clients can tell the user what exists. */ - private async resolveAgent(user: User, agentId: string): Promise { + private async resolveAgent(user: User, agentId: string): Promise { const agent = await this.agentsService.findByIdForUser(agentId, user); if (!agent) throw new UserError(`Agent "${agentId}" not found`); if (!agent.availableInMCP) { diff --git a/packages/cli/src/modules/n8n-packages/entities/agent/__tests__/agent.exporter.test.ts b/packages/cli/src/modules/n8n-packages/entities/agent/__tests__/agent.exporter.test.ts index f85de57165f..72a375b5a71 100644 --- a/packages/cli/src/modules/n8n-packages/entities/agent/__tests__/agent.exporter.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/agent/__tests__/agent.exporter.test.ts @@ -132,6 +132,14 @@ describe('AgentExporter', () => { ); }); + it('rejects an instance agent', async () => { + const { agent, definitions, exporter } = setup(); + Object.assign(agent, { scope: 'instance', projectId: null }); + await expect(exporter.prepare(agent)).rejects.toThrow('Only project agents can be exported'); + expect(definitions.readDraft).not.toHaveBeenCalled(); + expect(definitions.readVersion).not.toHaveBeenCalled(); + }); + it('propagates draft revision conflicts', async () => { const { agent, definitions, exporter } = setup(); const conflict = new ConflictError('Agent was modified concurrently; please retry'); diff --git a/packages/cli/src/modules/n8n-packages/entities/agent/agent.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/agent/agent.exporter.ts index 9caa4915eb5..468b6cb5ef1 100644 --- a/packages/cli/src/modules/n8n-packages/entities/agent/agent.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/agent/agent.exporter.ts @@ -3,7 +3,7 @@ import { Service } from '@n8n/di'; import { UnexpectedError } from 'n8n-workflow'; import { AgentDefinitionService } from '@/modules/agents/agent-definition.service'; -import type { Agent } from '@/modules/agents/entities/agent.entity'; +import { type Agent, isProjectAgent } from '@/modules/agents/entities/agent.entity'; import type { AgentDefinition } from '@/modules/agents/utils/agent-definition'; import type { PreparedAgentExport } from './agent-export.types'; @@ -30,6 +30,12 @@ export class AgentExporter { agent: Agent, policy: ExportVersionPolicy = ExportVersionPolicy.Latest, ): Promise { + // The export queries return only project agents. An instance agent has no project to export from. + if (!isProjectAgent(agent)) { + throw new UnexpectedError('Only project agents can be exported', { + extra: { agentId: agent.id }, + }); + } if (agent.activeVersionId === null) { if (policy === ExportVersionPolicy.IgnoreUnpublished) return undefined; if (policy === ExportVersionPolicy.PublishedStrict) { diff --git a/packages/cli/src/modules/n8n-packages/entities/agent/agent.serializer.ts b/packages/cli/src/modules/n8n-packages/entities/agent/agent.serializer.ts index 1e7afca77ab..fd9c51d91ff 100644 --- a/packages/cli/src/modules/n8n-packages/entities/agent/agent.serializer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/agent/agent.serializer.ts @@ -23,6 +23,7 @@ type AgentPackageKeyHandling = { availableInMCP: 'copy'; project: 'exclude'; projectId: 'exclude'; + scope: 'exclude'; createdAt: 'exclude'; updatedAt: 'exclude'; versionId: 'exclude'; diff --git a/packages/cli/test/integration/agents/agent-conversation-state.test.ts b/packages/cli/test/integration/agents/agent-conversation-state.test.ts index 1f464e01739..8b9a6d955d7 100644 --- a/packages/cli/test/integration/agents/agent-conversation-state.test.ts +++ b/packages/cli/test/integration/agents/agent-conversation-state.test.ts @@ -138,7 +138,7 @@ describe('Agent conversation state', () => { id: 'thread-1', agentId: agent.id, agentName: agent.name, - projectId: agent.projectId, + projectId: agent.projectId as string, sessionNumber: 1, }), ); diff --git a/packages/cli/test/integration/agents/agent-skills-hub.test.ts b/packages/cli/test/integration/agents/agent-skills-hub.test.ts index 7dce2405cb6..325ad28f014 100644 --- a/packages/cli/test/integration/agents/agent-skills-hub.test.ts +++ b/packages/cli/test/integration/agents/agent-skills-hub.test.ts @@ -26,7 +26,11 @@ import { AgentUpdateBroadcaster } from '@/modules/agents/agent-update-broadcaste import { AgentValidationService } from '@/modules/agents/agent-validation.service'; import { AgentsService } from '@/modules/agents/agents.service'; import { AgentsSettingsService } from '@/modules/agents/agents-settings.service'; -import type { Agent } from '@/modules/agents/entities/agent.entity'; +import { + type Agent, + isProjectAgent, + type ProjectAgent, +} from '@/modules/agents/entities/agent.entity'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import { SkillRepository } from '@/modules/agents/repositories/skill.repository'; import { SkillService, toAgentSkill } from '@/modules/agents/skills/skill.service'; @@ -121,11 +125,13 @@ describe('Agent skills in the skills hub', () => { }); } - async function reload(agent: Agent) { - return await agentRepository.findOneByOrFail({ id: agent.id }); + async function reload(agent: Agent): Promise { + const found = await agentRepository.findOneByOrFail({ id: agent.id }); + if (!isProjectAgent(found)) throw new Error(`Agent "${agent.id}" is not a project agent`); + return found; } - async function publish(agent: Agent) { + async function publish(agent: ProjectAgent) { await publishService.publishAgent(agent.id, agent.projectId, owner, { by: 'user', trigger: 'explicit', diff --git a/packages/cli/test/integration/database/repositories/agent-background-job.repository.test.ts b/packages/cli/test/integration/database/repositories/agent-background-job.repository.test.ts index cc9d8524c48..0746700b7b6 100644 --- a/packages/cli/test/integration/database/repositories/agent-background-job.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/agent-background-job.repository.test.ts @@ -26,7 +26,7 @@ import { REPLACED_PAUSE_GROUP_NOTICE, } from '@/modules/agents/background/background-job-messages'; import type { AgentBackgroundJob } from '@/modules/agents/entities/agent-background-job.entity'; -import type { Agent } from '@/modules/agents/entities/agent.entity'; +import type { ProjectAgent } from '@/modules/agents/entities/agent.entity'; import { N8NCheckpointStorage } from '@/modules/agents/integrations/n8n-checkpoint-storage'; import type { ChatIntegrationRegistry } from '@/modules/agents/integrations/agent-chat-integration'; import { AgentBackgroundJobRepository } from '@/modules/agents/repositories/agent-background-job.repository'; @@ -60,7 +60,7 @@ describe('AgentBackgroundJobRepository', () => { integrations: [], tools: {}, skills: {}, - } as Partial); + } as Partial); await agentRepository.save(agent); agentId = agent.id; }); @@ -96,7 +96,7 @@ describe('AgentBackgroundJobRepository', () => { const agent = await agentRepository.findOneByOrFail({ id: agentId }); await Container.get(AgentExecutionThreadRepository).insert({ id: parentThreadId, - projectId: agent.projectId, + projectId: agent.projectId as string, agentId, agentName: agent.name, }); @@ -636,7 +636,7 @@ describe('AgentBackgroundJobRepository', () => { const agent = await agentRepository.findOneByOrFail({ id: agentId }); await threads.insert({ id: childThreadId, - projectId: agent.projectId, + projectId: agent.projectId as string, agentId, agentName: agent.name, }); diff --git a/packages/cli/test/integration/database/repositories/agent-instance-scope.repository.test.ts b/packages/cli/test/integration/database/repositories/agent-instance-scope.repository.test.ts new file mode 100644 index 00000000000..ca4cfab3b5f --- /dev/null +++ b/packages/cli/test/integration/database/repositories/agent-instance-scope.repository.test.ts @@ -0,0 +1,177 @@ +import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; +import { Container } from '@n8n/di'; +import { NotFoundError } from '@n8n/errors'; +import { v4 as uuid } from 'uuid'; + +import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; +import { getAgentOrThrow } from '@/modules/agents/utils/get-agent-or-throw'; + +describe('AgentRepository instance scope', () => { + let agentRepo: AgentRepository; + let projectId: string; + let projectAgentId: string; + const instanceAgentId = 'test-instance-agent'; + + beforeAll(async () => { + await testModules.loadModules(['agents']); + await testDb.init(); + agentRepo = Container.get(AgentRepository); + }); + + beforeEach(async () => { + projectId = (await createTeamProject()).id; + projectAgentId = uuid(); + await agentRepo.insert({ + id: projectAgentId, + name: 'Project agent', + projectId, + schema: null, + availableInMCP: true, + }); + await agentRepo.ensureInstanceAgent(instanceAgentId, 'Instance agent'); + // Match the project agent, so only the scope filter can tell them apart. + await agentRepo.update({ id: instanceAgentId }, { availableInMCP: true }); + }); + + afterEach(async () => { + await agentRepo.delete({}); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('ensureInstanceAgent', () => { + it('creates an instance agent without a project', async () => { + const row = await agentRepo.findOneByOrFail({ id: instanceAgentId }); + + expect(row).toMatchObject({ + name: 'Instance agent', + scope: 'instance', + projectId: null, + schema: null, + }); + }); + + it('renames an existing instance agent and does not create a second row', async () => { + await agentRepo.ensureInstanceAgent(instanceAgentId, 'Renamed agent'); + + const rows = await agentRepo.findBy({ id: instanceAgentId }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ name: 'Renamed agent', scope: 'instance' }); + }); + + it('refuses to take over a project agent with the same id', async () => { + await expect(agentRepo.ensureInstanceAgent(projectAgentId, 'Taken over')).rejects.toThrow( + 'exists as a project agent', + ); + + expect(await agentRepo.findOneByOrFail({ id: projectAgentId })).toMatchObject({ + name: 'Project agent', + scope: 'project', + projectId, + }); + }); + + it('creates one row when called concurrently for a new id', async () => { + const id = 'test-concurrent-instance-agent'; + + await Promise.all([ + agentRepo.ensureInstanceAgent(id, 'Concurrent agent'), + agentRepo.ensureInstanceAgent(id, 'Concurrent agent'), + agentRepo.ensureInstanceAgent(id, 'Concurrent agent'), + ]); + + const rows = await agentRepo.findBy({ id }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ name: 'Concurrent agent', scope: 'instance' }); + }); + }); + + describe('project-scoped writes', () => { + it('do not change an instance agent', async () => { + const before = await agentRepo.findOneByOrFail({ id: instanceAgentId }); + + expect(await agentRepo.saveDraftFenced({ ...before, name: 'Changed', schema: null })).toBe( + false, + ); + expect( + await agentRepo.setActiveVersionFenced(instanceAgentId, before.revision, { + activeVersionId: null, + versionId: uuid(), + }), + ).toBe(false); + expect(await agentRepo.updateIntegrations(instanceAgentId, [], before, uuid())).toBe(false); + expect(await agentRepo.claimSetupCompleted(instanceAgentId, new Date())).toBe(false); + await agentRepo.setAvailableInMCP([instanceAgentId], false); + await agentRepo.updateDraftSchema( + { id: instanceAgentId, schema: null, updatedAt: new Date() }, + {}, + ); + + const after = await agentRepo.findOneByOrFail({ id: instanceAgentId }); + expect(after).toMatchObject({ + name: before.name, + revision: before.revision, + versionId: before.versionId, + availableInMCP: true, + setupCompletedAt: null, + }); + expect(after.updatedAt).toEqual(before.updatedAt); + }); + }); + + describe('project-scoped queries', () => { + it('never return instance agents', async () => { + const ids = (rows: T[]) => rows.map((row) => row.id); + + expect(await agentRepo.findById(instanceAgentId)).toBeNull(); + expect(await agentRepo.getProjectIdById(instanceAgentId)).toBeNull(); + expect(await agentRepo.findBudgetAlertTarget(instanceAgentId)).toBeNull(); + expect(await agentRepo.findIntegrationState(instanceAgentId)).toBeNull(); + expect(await agentRepo.findActiveVersionId(instanceAgentId)).toBeNull(); + expect(await agentRepo.findChatReachableById(instanceAgentId, null)).toBeNull(); + + expect( + ids((await agentRepo.findByProjectIdsPaginated(null, { skip: 0, take: 50 })).data), + ).toEqual([projectAgentId]); + expect(ids(await agentRepo.findSummariesByProjectIds(null))).toEqual([projectAgentId]); + expect(ids(await agentRepo.findSummariesByIds([projectAgentId, instanceAgentId]))).toEqual([ + projectAgentId, + ]); + expect(ids(await agentRepo.findMcpAvailabilityCandidates({ all: true }))).toEqual([ + projectAgentId, + ]); + expect( + ids(await agentRepo.findMcpAvailabilityCandidates({ ids: [instanceAgentId] })), + ).toEqual([]); + expect(ids(await agentRepo.findDependencyIndexAgentIdsBatch(null, 50))).toEqual([ + projectAgentId, + ]); + expect( + ids(await agentRepo.findByIntegrationCredentialAnyProject('slack', 'credential', 'none')), + ).toEqual([]); + }); + + it('keep returning project agents', async () => { + expect(await agentRepo.findById(projectAgentId)).toMatchObject({ projectId }); + expect(await agentRepo.findByIdAndProjectId(projectAgentId, projectId)).toMatchObject({ + id: projectAgentId, + }); + expect(await agentRepo.getProjectIdById(projectAgentId)).toBe(projectId); + expect(await agentRepo.existsByIdAndProjectId(projectAgentId, projectId)).toBe(true); + }); + }); + + describe('getAgentOrThrow', () => { + it('reports an instance agent as not found', async () => { + await expect(getAgentOrThrow(agentRepo, instanceAgentId, projectId)).rejects.toThrow( + NotFoundError, + ); + }); + + it('reports a missing agent as not found', async () => { + await expect(getAgentOrThrow(agentRepo, uuid(), projectId)).rejects.toThrow(NotFoundError); + }); + }); +});