This commit is contained in:
Jaakko Husso 2026-10-16 22:08:54 +03:00 committed by GitHub
commit e860717e74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 775 additions and 393 deletions

View File

@ -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);

View File

@ -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<Agent> {
async function createAgent(projectId: string): Promise<ProjectAgent> {
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<Agent>({ id: agent.id, activeVersionId: null }),
mock<ProjectAgent>({ id: agent.id, activeVersionId: null }),
);
evalAgentExecutionService.executeWithLlmMock.mockImplementation(
async (_agentId, _user, _options, caseInput) =>
@ -206,7 +206,7 @@ describe('AgentEvalRunnerService (integration)', () => {
});
agentRepository.findByIdAndProjectId.mockResolvedValue(
mock<Agent>({ id: agent.id, activeVersionId: null }),
mock<ProjectAgent>({ id: agent.id, activeVersionId: null }),
);
evalAgentExecutionService.executeWithLlmMock.mockResolvedValue({
runId: '',

View File

@ -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<Agent>({ id: 'agent-1', activeVersionId: 'v-1' }),
mock<ProjectAgent>({ id: 'agent-1', activeVersionId: 'v-1' }),
);
runRepository.createRun.mockResolvedValue(mock({ id: 'run-1' }));
runRepository.isCancellationRequested.mockResolvedValue(false);

View File

@ -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<AgentEvalCaseGenerationService>();
agentConfigService = mock<AgentConfigService>();
agentRepository.findByIdAndProjectId.mockResolvedValue(mock<Agent>({ id: AGENT_ID }));
agentRepository.findByIdAndProjectId.mockResolvedValue(mock<ProjectAgent>({ id: AGENT_ID }));
datasetRepository.findByIdAndAgentId.mockResolvedValue(makeDataset());
runRepository.findByIdAndAgentId.mockResolvedValue(makeRun());
runRepository.findAndCountByDatasetIdAndAgentId.mockResolvedValue([[], 0]);

View File

@ -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',

View File

@ -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() {

View File

@ -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<Agent | null>();
const agent = createDeferredPromise<ProjectAgent | null>();
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<Agent>()` proxies nested fields, which breaks the
// A plain object: `mock<ProjectAgent>()` 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(

View File

@ -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> = {}): Agent {
function makeAgent(overrides: Partial<ProjectAgent> = {}): 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 };
}

View File

@ -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,

View File

@ -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({

View File

@ -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 {

View File

@ -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> = {}): Agent {
function makeAgent(overrides: Partial<ProjectAgent> = {}): ProjectAgent {
return {
id: agentId,
projectId,
@ -33,7 +33,7 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
tools: {},
skills: {},
...overrides,
} as unknown as Agent;
} as unknown as ProjectAgent;
}
function makeRuntime() {

View File

@ -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,

View File

@ -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<typeof mock<AgentRepository>> {
id: agentId,
projectId,
activeVersionId: null,
} as Agent);
} as ProjectAgent);
return repository;
}

View File

@ -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> = {}): 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);

View File

@ -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> = {}): Agent {
function makeAgent(
schema: AgentJsonConfig | null,
overrides: Partial<ProjectAgent> = {},
): ProjectAgent {
return {
id: 'agent-1',
schema,
@ -15,7 +18,7 @@ function makeAgent(schema: AgentJsonConfig | null, overrides: Partial<Agent> = {
tools: {},
skills: {},
...overrides,
} as Agent;
} as ProjectAgent;
}
describe('agent telemetry', () => {

View File

@ -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> = {},
): Agent {
overrides: Partial<ProjectAgent> = {},
): 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(

View File

@ -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();

View File

@ -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> = {}): Agent {
function makeAgent(overrides: Partial<ProjectAgent> = {}): ProjectAgent {
return {
id: agentId,
name: 'Support Agent',
@ -67,7 +67,7 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
tools: {},
skills: {},
...overrides,
} as unknown as Agent;
} as unknown as ProjectAgent;
}
function makeReadableStream(chunks: StreamChunk[]): ReadableStream<StreamChunk> {
@ -502,7 +502,7 @@ describe('AgentWorkflowExecutionService', () => {
async (compilation) => {
const { service, agentRepository, reconstructionService, executionService } = makeService();
const lookupStarted = createDeferredPromise();
const agentLookup = createDeferredPromise<Agent>();
const agentLookup = createDeferredPromise<ProjectAgent>();
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 = {

View File

@ -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<Agent>({ 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' },
});
});
});

View File

@ -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> = {}): 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);

View File

@ -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<AgentJsonConfig>,
): 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>();
agentRepository.findByIdAndProjectId.mockResolvedValue(
mock<Agent>({ id: 'agent-2', name: 'Researcher', activeVersionId: 'version-1' }),
mock<ProjectAgent>({ 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>();
agentRepository.findByIdAndProjectId.mockResolvedValue(mock<Agent>({ id: 'disabled-agent' }));
agentRepository.findByIdAndProjectId.mockResolvedValue(
mock<ProjectAgent>({ id: 'disabled-agent' }),
);
const service = makeReconstructionService({ agentRepository });
await service.reconstructFromAgentEntity(
storedRunSource(

View File

@ -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<AgentRepository>, entity
Object.assign(entity, fields)) as never);
}
function makeAgent(overrides: Partial<Agent> = {}): Agent {
function makeAgent(overrides: Partial<ProjectAgent> = {}): ProjectAgent {
return {
id: agentId,
name: 'Agent',
@ -52,7 +52,7 @@ function makeAgent(overrides: Partial<Agent> = {}): 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<Agent>),
} as unknown as Partial<ProjectAgent>),
);
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<Agent>),
} as unknown as Partial<ProjectAgent>),
);
agentTaskRepository.findByAgentId.mockResolvedValue([]);

View File

@ -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',

View File

@ -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<Agent>({ id: 'agent-9', name: 'New agent' }),
agent: mock<ProjectAgent>({ 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<Agent>({ id: 'aBcDeFgHiJkLmNoP', name: 'New agent' }),
agent: mock<ProjectAgent>({ 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<Agent>({ id: 'aBcDeFgHiJkLmNoP', name: 'Support Triage' }),
agent: mock<ProjectAgent>({ 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<Agent>({ id: 'agent-1', name: 'Support Bot' }));
agentsService.findById.mockResolvedValue(
mock<ProjectAgent>({ id: 'agent-1', name: 'Support Bot' }),
);
await expect(delegate.resolveAgentName('agent-1')).resolves.toBe('Support Bot');
expect(agentsService.findById).toHaveBeenCalledWith('agent-1', 'project-1');

View File

@ -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<T extends Agent>(
agent: T,
skills: Record<string, AgentSkill> = {},
): AgentRunSource {
): AgentRunSource<T> {
return { agent, skillRefs: storedSkillRefs(agent), skills };
}

View File

@ -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<void> {
@ -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<ResolvedSubAgentRef[]> {
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');

View File

@ -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,

View File

@ -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<boolean> {
private async startRuntimeQuietly(
agent: ProjectAgent,
add: AgentIntegrationConfig,
): Promise<boolean> {
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<boolean> {
@ -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<void> {
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<void> {
@ -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<void> {
private async releaseRuntimeQuietly(
agent: ProjectAgent,
integration: IntegrationRef,
): Promise<void> {
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<void> {

View File

@ -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<IntegrationDeltaResult> {
@ -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<typeof createAgentCredentialProvider>,
@ -247,7 +247,7 @@ export class AgentIntegrationPersistenceService {
}
private async persistIntegrations(
agent: Agent,
agent: ProjectAgent,
skillRefs: AgentSkillRefs,
integrations: AgentIntegrationConfig[],
state: Pick<Agent, 'revision' | 'versionId' | 'activeVersionId'>,

View File

@ -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<Array<Pick<Agent, 'id' | 'projectId' | 'availableInMCP'>>> {
): Promise<Array<ProjectScoped<Pick<Agent, 'id' | 'projectId' | 'availableInMCP'>>>> {
if (dto.agentIds) {
return await this.agentRepository.findMcpAvailabilityCandidates({
ids: [...new Set(dto.agentIds)],

View File

@ -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<string, AgentTask>,
@ -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<void> {
private async assertChannelsStartable(agent: ProjectAgent, projectId: string): Promise<void> {
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<Agent> {
): Promise<ProjectAgent> {
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<Agent> {
): Promise<ProjectAgent> {
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<Agent> {
): Promise<ProjectAgent> {
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<void> {
private async startPublishedServices(agent: ProjectAgent): Promise<void> {
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<void> {
private async commitUnpublication(agent: ProjectAgent, expectedRevision: number): Promise<void> {
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<string, AgentTask>,
@ -674,7 +674,7 @@ export class AgentPublishService {
private async preparePublishedVersion(
ctx: OperationContext,
agent: Agent,
agent: ProjectAgent,
user: User,
tasks: ReadonlyMap<string, AgentTask>,
targetHistory?: AgentHistory,
@ -694,7 +694,7 @@ export class AgentPublishService {
private async saveDraftHistory(
ctx: OperationContext,
agent: Agent,
agent: ProjectAgent,
user: User,
versionId: string,
) {

View File

@ -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<void> {
private async reconcileAgent(agent: ProjectAgent): Promise<void> {
// 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<void> {
private async runTask(agent: ProjectAgent, snapshot: AgentTaskSnapshot): Promise<void> {
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<void> {
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<void> {

View File

@ -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<Agent> {
async create(
projectId: string,
name: string,
options: CreateAgentOptions,
): Promise<ProjectAgent> {
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<Agent | null> {
async findById(agentId: string, projectId: string): Promise<ProjectAgent | null> {
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<Agent | null> {
async findByIdForUser(agentId: string, user: User): Promise<ProjectAgent | null> {
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<Agent> {
): Promise<ProjectAgent> {
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<AgentCapabilityTool>((tool) => {
switch (tool.type) {
case 'custom':
@ -603,7 +612,7 @@ export class AgentsService {
});
}
private async getCapabilityTasks(entity: Agent): Promise<AgentCapabilitySummary['tasks']> {
private async getCapabilityTasks(entity: ProjectAgent): Promise<AgentCapabilitySummary['tasks']> {
const taskRefs = entity.schema?.tasks ?? [];
let taskNamesById: Record<string, string> = {};
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 } : {}),

View File

@ -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,

View File

@ -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<AgentEntity>({
const agent = mock<ProjectAgent>({
id: 'agent-1',
schema: null,
integrations: [],

View File

@ -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 extends Pick<Agent, 'projectId'>> = T & { projectId: string };
/** A project agent. Project-scoped repository queries return only these. */
export type ProjectAgent = ProjectScoped<Agent>;
/**
* 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<T extends Pick<Agent, 'projectId'>>(
agent: T,
): agent is ProjectScoped<T> {
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;

View File

@ -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 {

View File

@ -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> = {}): Agent {
function makeAgent(overrides: Partial<ProjectAgent> = {}): ProjectAgent {
return {
id: 'agent-1',
projectId: 'project-1',
@ -96,7 +96,7 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
activeVersionId: null,
activeVersion: null,
...overrides,
} as unknown as Agent;
} as unknown as ProjectAgent;
}
const slackIntegration: AgentIntegrationConfig = {

View File

@ -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());

View File

@ -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',

View File

@ -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<Agent>({ name: 'Support bot' }),
mock<ProjectAgent>({ name: 'Support bot' }),
]);
await expect(integration.onBeforeConnect(connectionContext())).rejects.toThrow(ConflictError);

View File

@ -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<string, { agent: Agent; integration: AgentIntegrationConfig }>;
type WantedChannels = Map<string, { agent: ProjectAgent; integration: AgentIntegrationConfig }>;
/**
* 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,

View File

@ -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<void> {
@ -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<void> {
async startChannel(agent: ProjectAgent, integration: AgentIntegrationConfig): Promise<void> {
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<ChatInstance | undefined> {
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`);
}

View File

@ -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,

View File

@ -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<AgentChatIntegrationContext> = {},

View File

@ -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<boolean> {
async isAppConfiguredForAgent(
credentialId: string,
agent: ProjectAgent,
user: User,
): Promise<boolean> {
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<boolean> {
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<string, unknown>,
): Promise<InstallSlackManagedAppResponse> {
@ -864,7 +868,7 @@ export class SlackManagedSetupService {
private async createManagedAppSession(
options: InstallManagedSlackAppOptions,
agent: Agent,
agent: ProjectAgent,
manager: ManagerCredentialContext,
workspaceName: string,
key: string,

View File

@ -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<Agent> {
async getAgent(agentId: string, projectId: string): Promise<ProjectAgent> {
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,

View File

@ -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<Agent>({
mock<ProjectAgent>({
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<Agent>({ name: 'Sales Bot' }),
mock<ProjectAgent>({ name: 'Sales Bot' }),
]);
};

View File

@ -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<Agent> {
private async getAgent(scope: AgentScope): Promise<ProjectAgent> {
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,

View File

@ -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,
});
}

View File

@ -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<AgentWorkf
})
: await manager.findOne(Agent, { where: { id: agentId } });
if (agent === null) {
// An instance agent has no project, so it has no project workflows to depend on.
if (agent === null || !isProjectAgent(agent)) {
if (isPostgres) await manager.delete(AgentWorkflowDependency, { agentId });
return;
}

View File

@ -9,22 +9,33 @@ import {
type OperationContext,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { UnexpectedError } from 'n8n-workflow';
import { DataSource, In, IsNull, Not, type SelectQueryBuilder } from '@n8n/typeorm';
import { randomUUID } from 'node:crypto';
import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
import { Agent } from '../entities/agent.entity';
import {
Agent,
isProjectAgent,
type ProjectAgent,
type ProjectScoped,
} from '../entities/agent.entity';
export interface AgentListResult {
count: number;
data: Agent[];
data: ProjectAgent[];
}
export type AgentSummary = Pick<
Agent,
'id' | 'name' | 'projectId' | 'activeVersionId' | 'availableInMCP' | 'updatedAt'
export type AgentSummary = ProjectScoped<
Pick<Agent, 'id' | 'name' | 'projectId' | 'activeVersionId' | 'availableInMCP' | 'updatedAt'>
>;
/** 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<Agent, 'integrations' | 'versionId' | 'activeVersionId'>;
@ -56,30 +67,35 @@ export class AgentRepository extends BaseRepository<Agent> {
return await this.existsBy({ id, revision });
}
async findByProjectId(projectId: string): Promise<Agent[]> {
return await this.find({
where: { projectId },
async findByProjectId(projectId: string): Promise<ProjectAgent[]> {
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<Agent[]> {
): Promise<ProjectAgent[]> {
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> {
'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<Agent> {
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<Agent> {
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<Agent> {
query: SelectQueryBuilder<Agent>,
projectIds: string[] | null,
): SelectQueryBuilder<Agent> {
query.andWhere(projectScopeSql);
if (projectIds !== null) {
query.andWhere('agent.projectId IN (:...projectIds)', { projectIds });
}
@ -215,7 +234,10 @@ export class AgentRepository extends BaseRepository<Agent> {
* 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<Agent | null> {
async findChatReachableById(
id: string,
projectIds: string[] | null,
): Promise<ProjectAgent | null> {
if (projectIds?.length === 0) return null;
const query = this.createQueryBuilder('agent')
@ -223,7 +245,7 @@ export class AgentRepository extends BaseRepository<Agent> {
.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<Agent> {
* 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<Agent | null> {
return await this.findOne({
where: { id, projectId },
relations: { activeVersion: true },
});
async findByIdAndProjectId(id: string, projectId: string): Promise<ProjectAgent | null> {
return projectAgentOrNull(
await this.findOne({
where: { id, projectId, scope: projectScope },
relations: { activeVersion: true },
}),
);
}
async isN8nChatPublished(id: string, projectId: string): Promise<boolean> {
@ -348,13 +372,37 @@ export class AgentRepository extends BaseRepository<Agent> {
/**
* 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<Agent | null> {
return await this.findOne({
where: { id },
relations: { activeVersion: true },
});
async findById(id: string): Promise<ProjectAgent | null> {
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<void> {
// 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<Agent> {
): Promise<Array<Pick<Agent, 'id'>>> {
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<Agent> {
async findSummariesByIds(
ids: string[],
): Promise<Array<Pick<Agent, 'id' | 'name' | 'projectId'>>> {
): Promise<Array<ProjectScoped<Pick<Agent, 'id' | 'name' | 'projectId'>>>> {
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<Agent | null> {
async findByIdInProjects(id: string, projectIds: string[]): Promise<ProjectAgent | null> {
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<boolean> {
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<string | null> {
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<Pick<Agent, 'name' | 'projectId'> | null> {
async findBudgetAlertTarget(
id: string,
): Promise<ProjectScoped<Pick<Agent, 'name' | 'projectId'>> | 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<Agent> {
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<Array<Pick<Agent, 'id' | 'projectId' | 'availableInMCP'>>> {
): Promise<Array<ProjectScoped<Pick<Agent, 'id' | 'projectId' | 'availableInMCP'>>>> {
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<void> {
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<Agent> {
*/
async claimSetupCompleted(id: string, completedAt: Date): Promise<boolean> {
const result = await this.update(
{ id, setupCompletedAt: IsNull() },
{ id, scope: projectScope, setupCompletedAt: IsNull() },
{ setupCompletedAt: completedAt },
);
@ -469,7 +524,7 @@ export class AgentRepository extends BaseRepository<Agent> {
async findIntegrationState(id: string): Promise<AgentIntegrationState | null> {
return await this.findOne({
select: ['integrations', 'versionId', 'activeVersionId'],
where: { id },
where: { id, scope: projectScope },
});
}
@ -487,6 +542,7 @@ export class AgentRepository extends BaseRepository<Agent> {
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<Agent> {
return (result.affected ?? 0) > 0;
}
async findPublished(): Promise<Agent[]> {
return await this.createQueryBuilder('agent')
async findPublished(): Promise<ProjectAgent[]> {
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<string[]> {
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<Agent> {
*/
async findActiveVersionId(agentId: string): Promise<string | null> {
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<Agent> {
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<Agent> {
credentialId: string,
excludeAgentId: string,
): Promise<Array<Pick<Agent, 'id' | 'name' | 'integrations'>>> {
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<Agent> {
credentialId: string,
projectId: string,
excludeAgentId: string,
): Promise<Agent[]> {
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<ProjectAgent[]> {
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<Agent> {
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<Agent> {
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<Agent> {
.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<Agent> {
async findProjectIdsByIds(
ids: string[],
ctx: OperationContext = {},
): Promise<Array<Pick<Agent, 'id' | 'projectId'>>> {
): Promise<Array<ProjectScoped<Pick<Agent, 'id' | 'projectId'>>>> {
const agents: Array<Pick<Agent, 'id' | 'projectId'>> = [];
// 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<Agent> {
})),
);
}
return agents;
return agents.filter(isProjectAgent);
}
/**
@ -779,6 +847,7 @@ export class AgentRepository extends BaseRepository<Agent> {
.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<Agent> {
.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;
}

View File

@ -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<Skill> {
.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<Skill> {
})
.setParameter('skillId', skillId)
.select(['agent.id', 'agent.name', 'agent.projectId', 'agent.activeVersionId'])
.getMany();
.getMany()
.then((agents) => agents.filter(isProjectAgent));
const isVisible = (agent: Pick<Agent, 'projectId'>) =>
const isVisible = (agent: ProjectScoped<Pick<Agent, 'projectId'>>) =>
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<Skill> {
async findAgents(
agentIds: string[],
ctx: OperationContext = {},
): Promise<Map<string, Pick<Agent, 'name' | 'projectId'>>> {
): Promise<Map<string, ProjectScoped<Pick<Agent, 'name' | 'projectId'>>>> {
const manager = this.managerFor(ctx);
const agents = await inChunks(
agentIds,
@ -705,7 +707,9 @@ export class SkillRepository extends BaseRepository<Skill> {
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 }]),
);
}
/**

View File

@ -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<Skill, 'projectId' | 'agentId'>,
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<SkillRef[]> {

View File

@ -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() {

View File

@ -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', () => {

View File

@ -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<T extends Agent = ProjectAgent> {
/** 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<string, AgentSkill>;
@ -26,25 +26,25 @@ function getPublishedVersion(agentEntity: Agent): AgentHistory {
return activeVersion;
}
function getPublishedAgentSnapshot(agentEntity: Agent): Agent {
function getPublishedAgentSnapshot<T extends Agent>(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<T extends Agent>(
agentEntity: T,
usePublishedVersion: boolean,
agentSkillRefs: AgentSkillRefsService,
ctx: OperationContext,
): Promise<AgentRunSource> {
): Promise<AgentRunSource<T>> {
if (!usePublishedVersion) {
return { agent: agentEntity, ...(await agentSkillRefs.readDraft(agentEntity, ctx)) };
}

View File

@ -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<Agent> {
): Promise<ProjectAgent> {
const agent = await repository.findByIdAndProjectId(agentId, projectId);
if (!agent) throw new NotFoundError(message);
return agent;

View File

@ -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<Agent>({ id: AGENT_ID, name: 'New Agent', projectId: PROJECT_ID }),
mock<ProjectAgent>({ 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<Agent>({ id: AGENT_ID, name: 'New Agent' }));
agentsService.create.mockResolvedValue(mock<ProjectAgent>({ 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<Agent>({ id: AGENT_ID, name: 'Support Triage' });
const existing = mock<ProjectAgent>({ 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<Agent>({ id: AGENT_ID, name: 'Support Triage', projectId: PROJECT_ID }),
mock<ProjectAgent>({ 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<Agent>({ id: AGENT_ID, name: 'Support Triage' }));
agentsService.create.mockResolvedValue(
mock<ProjectAgent>({ 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<Agent>({ id: AGENT_ID }));
agentsService.findById.mockResolvedValue(mock<ProjectAgent>({ 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<Agent>({ id: AGENT_ID, name: 'New Agent' }));
agentsService.create.mockResolvedValue(mock<ProjectAgent>({ 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<Agent>({ id: AGENT_ID, name: 'New Agent' }));
agentsService.create.mockResolvedValue(mock<ProjectAgent>({ 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<Agent>({ id: AGENT_ID, name: 'New Agent' }));
agentsService.create.mockResolvedValue(mock<ProjectAgent>({ 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<Agent>({ id: AGENT_ID, name: 'New Agent' }));
agentsService.create.mockResolvedValue(mock<ProjectAgent>({ id: AGENT_ID, name: 'New Agent' }));
await expect(service.persistAndBind(user, THREAD_ID, payload)).resolves.toBeDefined();
});

View File

@ -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<string, unknown> = {}): Agent =>
const agentEntity = (overrides: Record<string, unknown> = {}): ProjectAgent =>
({
id: 'agent-1',
name: 'My Agent',
@ -132,7 +132,7 @@ const agentEntity = (overrides: Record<string, unknown> = {}): 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<AgentRepository>();
const runtimeCacheService = mock<AgentRuntimeCacheService>();
const lifecycleTelemetry = mock<Telemetry>();

View File

@ -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,

View File

@ -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<AgentJsonConfig> {
private async configFromEntity(agent: ProjectAgent): Promise<AgentJsonConfig> {
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<Record<string, unknown>> {
@ -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<Agent> {
private async resolveAgent(user: User, agentId: string): Promise<ProjectAgent> {
const agent = await this.agentsService.findByIdForUser(agentId, user);
if (!agent) throw new UserError(`Agent "${agentId}" not found`);
if (!agent.availableInMCP) {

View File

@ -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');

View File

@ -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<PreparedAgentExport | undefined> {
// 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) {

View File

@ -23,6 +23,7 @@ type AgentPackageKeyHandling = {
availableInMCP: 'copy';
project: 'exclude';
projectId: 'exclude';
scope: 'exclude';
createdAt: 'exclude';
updatedAt: 'exclude';
versionId: 'exclude';

View File

@ -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,
}),
);

View File

@ -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<ProjectAgent> {
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',

View File

@ -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<Agent>);
} as Partial<ProjectAgent>);
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,
});

View File

@ -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 = <T extends { id: string }>(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);
});
});
});