mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-16 21:19:43 +02:00
Merge e7165c662a into ec074519d9
This commit is contained in:
commit
e860717e74
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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'>,
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
]);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ type AgentPackageKeyHandling = {
|
|||
availableInMCP: 'copy';
|
||||
project: 'exclude';
|
||||
projectId: 'exclude';
|
||||
scope: 'exclude';
|
||||
createdAt: 'exclude';
|
||||
updatedAt: 'exclude';
|
||||
versionId: 'exclude';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user