mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-22 04:21:32 +02:00
feat(core): Resolve Agent package import identities (no-changelog) (#40479)
This commit is contained in:
parent
800821d227
commit
f54fc2ebfb
|
|
@ -91,6 +91,10 @@ export class AgentTaskService {
|
|||
return tasks.map((task) => this.toDto(task));
|
||||
}
|
||||
|
||||
async findImportIdOwners(taskIds: string[]): Promise<Array<Pick<AgentTask, 'id' | 'agentId'>>> {
|
||||
return await this.taskRepository.findImportIdOwners(taskIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a task body and attach a `{ type:'task', id, enabled }` ref to the
|
||||
* agent's draft config in one transaction. Does not register a cron job —
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import { toAgentRef } from './utils/agent-ref';
|
|||
import { AgentTaskRepository } from './repositories/agent-task.repository';
|
||||
import {
|
||||
AgentRepository,
|
||||
type AgentImportIdentity,
|
||||
type AgentSummary,
|
||||
type AgentListResult,
|
||||
type AgentSummaryFilters,
|
||||
|
|
@ -347,6 +348,17 @@ export class AgentsService {
|
|||
return await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
}
|
||||
|
||||
async findImportCandidates(
|
||||
projectId: string,
|
||||
sourceAgentIds: string[],
|
||||
): Promise<AgentImportIdentity[]> {
|
||||
return await this.agentRepository.findImportCandidates(projectId, sourceAgentIds);
|
||||
}
|
||||
|
||||
async findImportIdOwners(agentIds: string[]): Promise<Array<Pick<Agent, 'id' | 'projectId'>>> {
|
||||
return await this.agentRepository.findImportIdOwners(agentIds);
|
||||
}
|
||||
|
||||
async isN8nChatPublished(agentId: string, projectId: string): Promise<boolean> {
|
||||
return await this.agentRepository.isN8nChatPublished(agentId, projectId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { AgentTaskConfig } from '@n8n/api-types';
|
||||
import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db';
|
||||
import { BaseRepository, chunkIds, TransactionRunner, type OperationContext } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { DataSource, In } from '@n8n/typeorm';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
|
@ -24,6 +24,14 @@ export class AgentTaskRepository extends BaseRepository<AgentTask> {
|
|||
return await this.findOne({ where: { id, agentId } });
|
||||
}
|
||||
|
||||
async findImportIdOwners(taskIds: string[]): Promise<Array<Pick<AgentTask, 'id' | 'agentId'>>> {
|
||||
const tasks: Array<Pick<AgentTask, 'id' | 'agentId'>> = [];
|
||||
for (const ids of chunkIds([...new Set(taskIds)])) {
|
||||
tasks.push(...(await this.find({ select: ['id', 'agentId'], where: { id: In(ids) } })));
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async saveDefinitions(tasks: AgentTask[], ctx: OperationContext): Promise<AgentTask[]> {
|
||||
return await this.managerFor(ctx).save(AgentTask, tasks);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ const projectScopeUpdateSql = "scope = 'project'";
|
|||
/** Integration and publication state for channel runtime decisions. */
|
||||
export type AgentIntegrationState = Pick<Agent, 'integrations' | 'versionId' | 'activeVersionId'>;
|
||||
|
||||
export type AgentImportIdentity = Pick<Agent, 'id' | 'name' | 'projectId'>;
|
||||
|
||||
export type AgentSummaryFilters = {
|
||||
query?: string;
|
||||
publishedOnly?: boolean;
|
||||
|
|
@ -119,6 +121,30 @@ export class AgentRepository extends BaseRepository<Agent> {
|
|||
return ids;
|
||||
}
|
||||
|
||||
async findImportCandidates(
|
||||
projectId: string,
|
||||
sourceAgentIds: string[],
|
||||
): Promise<AgentImportIdentity[]> {
|
||||
const agents: AgentImportIdentity[] = [];
|
||||
for (const ids of chunkIds([...new Set(sourceAgentIds)])) {
|
||||
agents.push(
|
||||
...(await this.find({
|
||||
select: ['id', 'name', 'projectId'],
|
||||
where: { projectId, id: In(ids) },
|
||||
})),
|
||||
);
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
async findImportIdOwners(agentIds: string[]): Promise<Array<Pick<Agent, 'id' | 'projectId'>>> {
|
||||
const agents: Array<Pick<Agent, 'id' | 'projectId'>> = [];
|
||||
for (const ids of chunkIds([...new Set(agentIds)])) {
|
||||
agents.push(...(await this.find({ select: ['id', 'projectId'], where: { id: In(ids) } })));
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lean listing for search surfaces: selects only summary columns, skipping
|
||||
* the JSON config columns and the activeVersion join, and pushes all
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
import { createTeamProject, linkUserToProject, testDb, testModules } from '@n8n/backend-test-utils';
|
||||
import type { Project, User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { ForbiddenError } from '@n8n/errors';
|
||||
|
||||
import { AgentTaskRepository } from '@/modules/agents/repositories/agent-task.repository';
|
||||
import { AgentRepository } from '@/modules/agents/repositories/agent.repository';
|
||||
import { createMember, createOwner } from '@test-integration/db/users';
|
||||
|
||||
import { looseAgentsFixture } from './fixtures/agent-package-fixtures';
|
||||
import { AgentImportMatchService } from '../entities/agent/agent-import-match.service';
|
||||
import type { PreparedAgent } from '../entities/agent/agent-import.types';
|
||||
import { serializedAgentSchema } from '../spec/serialized/agent.schema';
|
||||
|
||||
let service: AgentImportMatchService;
|
||||
let agents: AgentRepository;
|
||||
let tasks: AgentTaskRepository;
|
||||
let user: User;
|
||||
let project: Project;
|
||||
let otherProject: Project;
|
||||
const fixture = looseAgentsFixture();
|
||||
const sources: PreparedAgent[] = fixture.manifest.agents.map(({ target }) => {
|
||||
const { id, ...content } = serializedAgentSchema.parse(fixture.files[`${target}/agent.json`]);
|
||||
return { ...content, sourceAgentId: id, metadata: { versionId: null, publishedVersionId: null } };
|
||||
});
|
||||
const taskBody = sources[0].tasks.support_source_task;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testModules.loadModules(['agents']);
|
||||
await testDb.init();
|
||||
agents = Container.get(AgentRepository);
|
||||
tasks = Container.get(AgentTaskRepository);
|
||||
service = Container.get(AgentImportMatchService);
|
||||
user = await createOwner();
|
||||
project = await createTeamProject('Destination', user);
|
||||
otherProject = await createTeamProject('Other destination', user);
|
||||
});
|
||||
beforeEach(async () => await agents.delete({}));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
afterAll(async () => await testDb.terminate());
|
||||
|
||||
async function seedAgent(id: string, projectId = project.id) {
|
||||
return await agents.save({ id, name: id, projectId });
|
||||
}
|
||||
|
||||
async function records() {
|
||||
return await Promise.all([
|
||||
agents.find({ order: { id: 'ASC' } }),
|
||||
tasks.find({ order: { id: 'ASC' } }),
|
||||
]);
|
||||
}
|
||||
|
||||
async function resolve(projectId = project.id) {
|
||||
return await service.allocateAgentIds(
|
||||
await service.findBySourceAgentIds(
|
||||
{ user, projectId },
|
||||
sources.map(({ sourceAgentId }) => sourceAgentId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
it('retains persisted Agent and task identities on re-import', async () => {
|
||||
const sourceBefore = structuredClone(sources);
|
||||
const identities = await resolve();
|
||||
const mapping = await service.mapTaskIds(sources, identities);
|
||||
expect(identities.idConflicts).toEqual([]);
|
||||
expect(mapping.idConflicts).toEqual([]);
|
||||
for (const source of sources) {
|
||||
const { targetAgentId } = identities.identities.get(source.sourceAgentId)!;
|
||||
await seedAgent(targetAgentId);
|
||||
expect(targetAgentId).toBe(source.sourceAgentId);
|
||||
for (const [sourceTaskId, body] of Object.entries(source.tasks)) {
|
||||
const id = mapping.taskIdsBySourceAgentId.get(source.sourceAgentId)!.get(sourceTaskId)!;
|
||||
await tasks.save({ ...body, id, agentId: targetAgentId });
|
||||
expect(id).toBe(sourceTaskId);
|
||||
}
|
||||
}
|
||||
const before = await records();
|
||||
const repeated = await resolve();
|
||||
expect([...repeated.identities.values()].map(({ targetAgentId }) => targetAgentId)).toEqual(
|
||||
[...identities.identities.values()].map(({ targetAgentId }) => targetAgentId),
|
||||
);
|
||||
expect((await service.mapTaskIds(sources, repeated)).taskIdsBySourceAgentId).toEqual(
|
||||
mapping.taskIdsBySourceAgentId,
|
||||
);
|
||||
expect(await records()).toEqual(before);
|
||||
expect(sources).toEqual(sourceBefore);
|
||||
});
|
||||
|
||||
it('reports an occupied Agent ID in another project under source policy', async () => {
|
||||
await seedAgent('support_source', otherProject.id);
|
||||
const before = await records();
|
||||
const result = await resolve();
|
||||
expect(result.identities.has('support_source')).toBe(false);
|
||||
expect(result.idConflicts).toEqual([
|
||||
{
|
||||
sourceAgentId: 'support_source',
|
||||
existingAgentId: 'support_source',
|
||||
existingProjectId: otherProject.id,
|
||||
},
|
||||
]);
|
||||
expect(await records()).toEqual(before);
|
||||
});
|
||||
|
||||
it('reports an occupied task ID owned by another Agent', async () => {
|
||||
await seedAgent('support_source');
|
||||
await seedAgent('other-agent', otherProject.id);
|
||||
await tasks.save({
|
||||
id: 'support_source_task',
|
||||
agentId: 'other-agent',
|
||||
...taskBody,
|
||||
});
|
||||
const before = await records();
|
||||
const result = await service.mapTaskIds(sources, await resolve());
|
||||
expect(result.taskIdsBySourceAgentId.get('support_source')!.size).toBe(0);
|
||||
expect(result.idConflicts).toEqual([
|
||||
{
|
||||
sourceAgentId: 'support_source',
|
||||
targetAgentId: 'support_source',
|
||||
sourceTaskId: 'support_source_task',
|
||||
targetTaskId: 'support_source_task',
|
||||
conflictingAgentId: 'other-agent',
|
||||
},
|
||||
]);
|
||||
expect(await records()).toEqual(before);
|
||||
});
|
||||
|
||||
it('keeps task and Agent ID namespaces separate', async () => {
|
||||
await seedAgent('support_source');
|
||||
const identities = await resolve();
|
||||
const result = await service.mapTaskIds(
|
||||
[{ sourceAgentId: 'support_source', tasks: { support_source: taskBody } }],
|
||||
identities,
|
||||
);
|
||||
expect(result.taskIdsBySourceAgentId.get('support_source')).toEqual(
|
||||
new Map([['support_source', 'support_source']]),
|
||||
);
|
||||
expect(result.idConflicts).toEqual([]);
|
||||
});
|
||||
|
||||
it('restricts destination matching to projects the user can read', async () => {
|
||||
await seedAgent('support_source');
|
||||
const member = await createMember();
|
||||
const read = vi.spyOn(agents, 'findImportCandidates');
|
||||
await expect(
|
||||
service.findBySourceAgentIds({ user: member, projectId: project.id }, ['support_source']),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
await linkUserToProject(member, project, 'project:viewer');
|
||||
const matches = await service.findBySourceAgentIds({ user: member, projectId: project.id }, [
|
||||
'support_source',
|
||||
]);
|
||||
expect(matches.matches.get('support_source')?.id).toBe('support_source');
|
||||
});
|
||||
|
||||
it('keeps global ID checks for an authorized pending project', async () => {
|
||||
await seedAgent('support_source');
|
||||
const member = await createMember();
|
||||
const read = vi.spyOn(agents, 'findImportCandidates');
|
||||
const matches = await service.findBySourceAgentIds(
|
||||
{ user: member, projectId: 'pending-project', projectPendingCreation: true },
|
||||
['support_source'],
|
||||
);
|
||||
const result = await service.allocateAgentIds(matches);
|
||||
expect(result.identities.size).toBe(0);
|
||||
expect(result.idConflicts).toEqual([
|
||||
{
|
||||
sourceAgentId: 'support_source',
|
||||
existingAgentId: 'support_source',
|
||||
existingProjectId: project.id,
|
||||
},
|
||||
]);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bounds candidate and ownership queries for large ID lists', async () => {
|
||||
await seedAgent('id-0');
|
||||
await seedAgent('id-32999');
|
||||
await seedAgent('outside-agent', otherProject.id);
|
||||
await tasks.save([
|
||||
{ id: 'id-0', agentId: 'id-0', ...taskBody },
|
||||
{ id: 'id-32999', agentId: 'id-0', ...taskBody },
|
||||
{ id: 'outside-task', agentId: 'outside-agent', ...taskBody },
|
||||
]);
|
||||
const ids = Array.from({ length: 33_000 }, (_, index) => `id-${index}`);
|
||||
const candidates = await agents.findImportCandidates(project.id, ids);
|
||||
expect(candidates.map(({ id }) => id).sort()).toEqual(['id-0', 'id-32999']);
|
||||
expect(candidates.every((candidate) => !Object.hasOwn(candidate, 'schema'))).toBe(true);
|
||||
expect(await agents.findImportIdOwners(ids)).toMatchObject([
|
||||
{ id: 'id-0', projectId: project.id },
|
||||
{ id: 'id-32999', projectId: project.id },
|
||||
]);
|
||||
expect(await tasks.findImportIdOwners(ids)).toMatchObject([
|
||||
{ id: 'id-0', agentId: 'id-0' },
|
||||
{ id: 'id-32999', agentId: 'id-0' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty results without database reads', async () => {
|
||||
const agentRead = vi.spyOn(agents, 'find');
|
||||
const taskRead = vi.spyOn(tasks, 'find');
|
||||
const matches = await service.findBySourceAgentIds({ user, projectId: project.id }, []);
|
||||
const identities = await service.allocateAgentIds(matches);
|
||||
expect((await service.mapTaskIds([], identities)).taskIdsBySourceAgentId.size).toBe(0);
|
||||
expect(await agents.findImportCandidates(project.id, [])).toEqual([]);
|
||||
expect(agentRead).not.toHaveBeenCalled();
|
||||
expect(taskRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -135,7 +135,11 @@ beforeAll(async () => {
|
|||
revision: 0,
|
||||
});
|
||||
await taskRepository.save(
|
||||
Object.entries(draft.tasks).map(([id, task]) => ({ id, agentId: agent.id, ...task })),
|
||||
Object.entries(draft.tasks).map(([id, task]) => ({
|
||||
id,
|
||||
agentId: agent.id,
|
||||
...task,
|
||||
})),
|
||||
);
|
||||
const history = await historyRepository.saveVersion({
|
||||
versionId: `${agent.id}_published`,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { decideImportId } from '../import-id-policy';
|
||||
|
||||
describe('decideImportId', () => {
|
||||
describe('source', () => {
|
||||
it('keeps the source id', () => {
|
||||
expect(decideImportId('source', 'STILTON')).toBe('STILTON');
|
||||
});
|
||||
});
|
||||
|
||||
describe('new', () => {
|
||||
it('mints a fresh id, discarding the source id', () => {
|
||||
const id = decideImportId('new', 'STILTON');
|
||||
|
||||
expect(id).toEqual(expect.any(String));
|
||||
expect(id).not.toBe('STILTON');
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('mints a different id on each call', () => {
|
||||
expect(decideImportId('new', 'STILTON')).not.toBe(decideImportId('new', 'STILTON'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import type { ProjectScopeService } from '@n8n/backend-services';
|
||||
import type { User } from '@n8n/db';
|
||||
import { ForbiddenError } from '@n8n/errors';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { AgentTaskService } from '@/modules/agents/agent-task.service';
|
||||
import type { AgentsService } from '@/modules/agents/agents.service';
|
||||
|
||||
import { looseAgentsFixture } from '../../../__tests__/fixtures/agent-package-fixtures';
|
||||
import { serializedAgentSchema } from '../../../spec/serialized/agent.schema';
|
||||
import { AgentImportMatchService } from '../agent-import-match.service';
|
||||
|
||||
function setup() {
|
||||
const projects = mock<ProjectScopeService>();
|
||||
projects.getProjectIds.mockResolvedValue(null);
|
||||
const agents = mock<AgentsService>();
|
||||
agents.findImportCandidates.mockResolvedValue([]);
|
||||
agents.findImportIdOwners.mockResolvedValue([]);
|
||||
const tasks = mock<AgentTaskService>();
|
||||
tasks.findImportIdOwners.mockResolvedValue([]);
|
||||
return {
|
||||
projects,
|
||||
agents,
|
||||
tasks,
|
||||
context: { user: mock<User>(), projectId: 'destination' },
|
||||
service: new AgentImportMatchService(projects, agents, tasks),
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentImportMatchService', () => {
|
||||
it('checks project read access before reading destination Agents', async () => {
|
||||
const { service, projects, agents, context } = setup();
|
||||
projects.getProjectIds.mockResolvedValue(['different-project']);
|
||||
await expect(service.findBySourceAgentIds(context, ['source'])).rejects.toThrow(ForbiddenError);
|
||||
expect(projects.getProjectIds).toHaveBeenCalledWith(context.user, ['agent:read']);
|
||||
expect(agents.findImportCandidates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits both task mappings when two proposed owners use one ID', async () => {
|
||||
const { service, context } = setup();
|
||||
const content = serializedAgentSchema.parse(
|
||||
looseAgentsFixture().files['agents/support/agent.json'],
|
||||
);
|
||||
const tasks = { shared_task: content.tasks.support_source_task };
|
||||
const sources = ['first', 'second'].map((sourceAgentId) => ({ sourceAgentId, tasks }));
|
||||
const identities = await service.allocateAgentIds(
|
||||
await service.findBySourceAgentIds(context, ['first', 'second']),
|
||||
);
|
||||
const result = await service.mapTaskIds(sources, identities);
|
||||
expect([...result.taskIdsBySourceAgentId.values()].map((map) => map.size)).toEqual([0, 0]);
|
||||
expect(result.idConflicts).toEqual([
|
||||
{
|
||||
sourceAgentId: 'first',
|
||||
targetAgentId: 'first',
|
||||
sourceTaskId: 'shared_task',
|
||||
targetTaskId: 'shared_task',
|
||||
conflictingAgentId: 'second',
|
||||
},
|
||||
{
|
||||
sourceAgentId: 'second',
|
||||
targetAgentId: 'second',
|
||||
sourceTaskId: 'shared_task',
|
||||
targetTaskId: 'shared_task',
|
||||
conflictingAgentId: 'first',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -267,7 +267,7 @@ describe('AgentExporter', () => {
|
|||
expect(JSON.parse(writer.files[1].content)).toEqual(expected.metadata);
|
||||
});
|
||||
|
||||
it('keeps authored bytes and hashes stable when only publication metadata changes', async () => {
|
||||
it('keeps authored bytes and hashes stable when publication metadata changes', async () => {
|
||||
const { agent, exporter } = setup();
|
||||
const before = (await exporter.prepare(agent))!;
|
||||
agent.versionId = 'new-draft-version';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import { ProjectScopeService } from '@n8n/backend-services';
|
||||
import { Service } from '@n8n/di';
|
||||
import { ForbiddenError } from '@n8n/errors';
|
||||
|
||||
import { AgentTaskService } from '@/modules/agents/agent-task.service';
|
||||
import { AgentsService } from '@/modules/agents/agents.service';
|
||||
|
||||
import type {
|
||||
AgentImportIdentities,
|
||||
AgentImportMatchContext,
|
||||
AgentImportMatches,
|
||||
AgentTaskImportMappings,
|
||||
PreparedAgent,
|
||||
} from './agent-import.types';
|
||||
|
||||
interface TaskMapping {
|
||||
sourceAgentId: string;
|
||||
targetAgentId: string;
|
||||
sourceTaskId: string;
|
||||
targetTaskId: string;
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class AgentImportMatchService {
|
||||
constructor(
|
||||
private readonly projectScopeService: ProjectScopeService,
|
||||
private readonly agentsService: AgentsService,
|
||||
private readonly agentTaskService: AgentTaskService,
|
||||
) {}
|
||||
|
||||
async findBySourceAgentIds(
|
||||
context: AgentImportMatchContext,
|
||||
sourceAgentIds: string[],
|
||||
): Promise<AgentImportMatches> {
|
||||
const result: AgentImportMatches = {
|
||||
projectId: context.projectId,
|
||||
sourceAgentIds: [...new Set(sourceAgentIds)],
|
||||
matches: new Map(),
|
||||
};
|
||||
if (sourceAgentIds.length === 0 || context.projectPendingCreation) return result;
|
||||
|
||||
const readableProjects = await this.projectScopeService.getProjectIds(context.user, [
|
||||
'agent:read',
|
||||
]);
|
||||
if (readableProjects !== null && !readableProjects.includes(context.projectId)) {
|
||||
throw new ForbiddenError(
|
||||
'You do not have permission to read Agents in the destination project.',
|
||||
);
|
||||
}
|
||||
const candidates = await this.agentsService.findImportCandidates(
|
||||
context.projectId,
|
||||
result.sourceAgentIds,
|
||||
);
|
||||
result.matches = new Map(candidates.map((agent) => [agent.id, agent]));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Allocate only after matching. The planner decides which definitions it will apply. */
|
||||
async allocateAgentIds(matches: AgentImportMatches): Promise<AgentImportIdentities> {
|
||||
const result: AgentImportIdentities = {
|
||||
projectId: matches.projectId,
|
||||
identities: new Map(),
|
||||
idConflicts: [],
|
||||
};
|
||||
const unmatched = matches.sourceAgentIds.filter((id) => !matches.matches.has(id));
|
||||
const owners = await this.agentsService.findImportIdOwners(unmatched);
|
||||
const ownersById = new Map(owners.map((owner) => [owner.id, owner]));
|
||||
|
||||
for (const sourceAgentId of matches.sourceAgentIds) {
|
||||
const owner = ownersById.get(sourceAgentId);
|
||||
if (owner) {
|
||||
result.idConflicts.push({
|
||||
sourceAgentId,
|
||||
existingAgentId: owner.id,
|
||||
existingProjectId: owner.projectId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const existing = matches.matches.get(sourceAgentId) ?? null;
|
||||
result.identities.set(sourceAgentId, {
|
||||
sourceAgentId,
|
||||
targetAgentId: sourceAgentId,
|
||||
existing,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async mapTaskIds(
|
||||
agents: Array<Pick<PreparedAgent, 'sourceAgentId' | 'tasks'>>,
|
||||
identities: AgentImportIdentities,
|
||||
): Promise<AgentTaskImportMappings> {
|
||||
const result: AgentTaskImportMappings = {
|
||||
taskIdsBySourceAgentId: new Map(),
|
||||
idConflicts: [],
|
||||
};
|
||||
const mappings: TaskMapping[] = [];
|
||||
for (const agent of agents) {
|
||||
const identity = identities.identities.get(agent.sourceAgentId);
|
||||
if (!identity) continue;
|
||||
result.taskIdsBySourceAgentId.set(agent.sourceAgentId, new Map());
|
||||
for (const sourceTaskId of Object.keys(agent.tasks)) {
|
||||
mappings.push({
|
||||
sourceAgentId: agent.sourceAgentId,
|
||||
targetAgentId: identity.targetAgentId,
|
||||
sourceTaskId,
|
||||
targetTaskId: sourceTaskId,
|
||||
});
|
||||
}
|
||||
}
|
||||
const owners = await this.agentTaskService.findImportIdOwners(
|
||||
mappings.map(({ targetTaskId }) => targetTaskId),
|
||||
);
|
||||
const ownersById = new Map(owners.map(({ id, agentId }) => [id, agentId]));
|
||||
const available: TaskMapping[] = [];
|
||||
for (const mapping of mappings) {
|
||||
const conflictingAgentId = ownersById.get(mapping.targetTaskId);
|
||||
if (conflictingAgentId !== undefined && conflictingAgentId !== mapping.targetAgentId) {
|
||||
result.idConflicts.push(toTaskIdConflict(mapping, conflictingAgentId));
|
||||
} else {
|
||||
available.push(mapping);
|
||||
}
|
||||
}
|
||||
collectTaskMappings(available, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function toTaskIdConflict(mapping: TaskMapping, conflictingAgentId: string) {
|
||||
const { sourceAgentId, sourceTaskId, targetAgentId, targetTaskId } = mapping;
|
||||
return { sourceAgentId, sourceTaskId, targetAgentId, targetTaskId, conflictingAgentId };
|
||||
}
|
||||
|
||||
function collectTaskMappings(mappings: TaskMapping[], result: AgentTaskImportMappings): void {
|
||||
const byTargetId = new Map<string, TaskMapping[]>();
|
||||
for (const mapping of mappings) {
|
||||
const group = byTargetId.get(mapping.targetTaskId) ?? [];
|
||||
group.push(mapping);
|
||||
byTargetId.set(mapping.targetTaskId, group);
|
||||
}
|
||||
for (const group of byTargetId.values()) {
|
||||
for (const mapping of group) {
|
||||
const other = group.find(({ targetAgentId }) => targetAgentId !== mapping.targetAgentId);
|
||||
if (other) {
|
||||
result.idConflicts.push(toTaskIdConflict(mapping, other.targetAgentId));
|
||||
continue;
|
||||
}
|
||||
result.taskIdsBySourceAgentId
|
||||
.get(mapping.sourceAgentId)
|
||||
?.set(mapping.sourceTaskId, mapping.targetTaskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,51 @@
|
|||
import type { AgentImportIdentity } from '@/modules/agents/repositories/agent.repository';
|
||||
|
||||
import type { ImportContext } from '../../n8n-packages.types';
|
||||
import type { SerializedAgent, SerializedAgentMetadata } from '../../spec/serialized/agent.schema';
|
||||
|
||||
export interface PreparedAgent extends Omit<SerializedAgent, 'id'> {
|
||||
sourceAgentId: string;
|
||||
metadata: SerializedAgentMetadata;
|
||||
}
|
||||
|
||||
export interface AgentImportMatchContext extends Pick<ImportContext, 'user' | 'projectId'> {
|
||||
/** Set only after the project planner authorizes creation of this destination. */
|
||||
projectPendingCreation?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentIdConflict {
|
||||
sourceAgentId: string;
|
||||
existingAgentId: string;
|
||||
existingProjectId: string | null;
|
||||
}
|
||||
|
||||
export interface AgentImportMatches {
|
||||
projectId: string;
|
||||
sourceAgentIds: string[];
|
||||
matches: Map<string, AgentImportIdentity>;
|
||||
}
|
||||
|
||||
export interface ResolvedAgentImportIdentity {
|
||||
sourceAgentId: string;
|
||||
targetAgentId: string;
|
||||
existing: AgentImportIdentity | null;
|
||||
}
|
||||
|
||||
export interface AgentImportIdentities {
|
||||
projectId: string;
|
||||
identities: Map<string, ResolvedAgentImportIdentity>;
|
||||
idConflicts: AgentIdConflict[];
|
||||
}
|
||||
|
||||
export interface AgentTaskIdConflict {
|
||||
sourceAgentId: string;
|
||||
targetAgentId: string;
|
||||
sourceTaskId: string;
|
||||
targetTaskId: string;
|
||||
conflictingAgentId: string;
|
||||
}
|
||||
|
||||
export interface AgentTaskImportMappings {
|
||||
taskIdsBySourceAgentId: Map<string, Map<string, string>>;
|
||||
idConflicts: AgentTaskIdConflict[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { generateNanoId } from '@n8n/utils/generate-nano-id';
|
||||
|
||||
import type { WorkflowIdPolicy } from '../n8n-packages.types';
|
||||
|
||||
/** Select an ID for a new import. Existing matches keep their IDs. */
|
||||
const IMPORT_ID_POLICIES: Record<WorkflowIdPolicy, (sourceId: string) => string> = {
|
||||
new: () => generateNanoId(),
|
||||
source: (sourceId) => sourceId,
|
||||
};
|
||||
|
||||
export function decideImportId(policy: WorkflowIdPolicy, sourceId: string): string {
|
||||
return IMPORT_ID_POLICIES[policy](sourceId);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/** A stored source identity excludes the local-ID fallback. Ambiguity excludes both. */
|
||||
export function matchSourceIds<T extends { id: string }>(
|
||||
sourceIds: string[],
|
||||
candidates: T[],
|
||||
getSourceId: (candidate: T) => string | null,
|
||||
) {
|
||||
const requested = new Set(sourceIds);
|
||||
const bySourceId = new Map<string, T[]>();
|
||||
for (const candidate of candidates) {
|
||||
const sourceId = getSourceId(candidate);
|
||||
if (sourceId === null || !requested.has(sourceId)) continue;
|
||||
const group = bySourceId.get(sourceId) ?? [];
|
||||
group.push(candidate);
|
||||
bySourceId.set(sourceId, group);
|
||||
}
|
||||
const matches = new Map<string, T>();
|
||||
const ambiguous = new Map<string, T[]>();
|
||||
for (const [sourceId, group] of bySourceId) {
|
||||
if (group.length === 1) matches.set(sourceId, group[0]);
|
||||
else
|
||||
ambiguous.set(
|
||||
sourceId,
|
||||
group.toSorted((left, right) => left.id.localeCompare(right.id)),
|
||||
);
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
getSourceId(candidate) !== null ||
|
||||
!requested.has(candidate.id) ||
|
||||
bySourceId.has(candidate.id)
|
||||
)
|
||||
continue;
|
||||
matches.set(candidate.id, candidate);
|
||||
}
|
||||
return { matches, ambiguous };
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { decideWorkflowId } from '../workflow-id-policy';
|
||||
|
||||
describe('decideWorkflowId', () => {
|
||||
describe('source', () => {
|
||||
it('keeps the source workflow id', () => {
|
||||
expect(decideWorkflowId('source', 'STILTON')).toBe('STILTON');
|
||||
});
|
||||
});
|
||||
|
||||
describe('new', () => {
|
||||
it('mints a fresh id, discarding the source id', () => {
|
||||
const id = decideWorkflowId('new', 'STILTON');
|
||||
|
||||
expect(id).toEqual(expect.any(String));
|
||||
expect(id).not.toBe('STILTON');
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('mints a different id on each call', () => {
|
||||
expect(decideWorkflowId('new', 'STILTON')).not.toBe(decideWorkflowId('new', 'STILTON'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -102,10 +102,12 @@ describe('WorkflowImportMatchService', () => {
|
|||
it('reports a conflict when two active workflows share a source id', async () => {
|
||||
const first = workflow({ id: 'local-1', sourceWorkflowId: 'wf-dup', name: 'First' });
|
||||
const second = workflow({ id: 'local-2', sourceWorkflowId: 'wf-dup', name: 'Second' });
|
||||
const { service } = makeService([first, second]);
|
||||
const authored = workflow({ id: 'wf-dup' });
|
||||
const { service } = makeService([authored, second, first]);
|
||||
|
||||
const result = await service.findBySourceWorkflowIds('project-1', ['wf-dup']);
|
||||
|
||||
expect(result.matches.get('wf-dup')).toBe(first);
|
||||
expect(result.lineageConflicts).toEqual([
|
||||
{
|
||||
sourceWorkflowId: 'wf-dup',
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import { generateNanoId } from '@n8n/db';
|
||||
|
||||
import type { WorkflowIdPolicy } from '../../n8n-packages.types';
|
||||
|
||||
/**
|
||||
* Decides the id a newly-created imported workflow gets. `new` mints a fresh id
|
||||
* (the source id is preserved separately as `sourceWorkflowId`); `source` reuses
|
||||
* the package's own id so re-imports map back to the same workflow.
|
||||
*/
|
||||
const WORKFLOW_ID_POLICIES: Record<WorkflowIdPolicy, (sourceWorkflowId: string) => string> = {
|
||||
new: () => generateNanoId(),
|
||||
source: (sourceWorkflowId) => sourceWorkflowId,
|
||||
};
|
||||
|
||||
export function decideWorkflowId(policy: WorkflowIdPolicy, sourceWorkflowId: string): string {
|
||||
return WORKFLOW_ID_POLICIES[policy](sourceWorkflowId);
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import { Service } from '@n8n/di';
|
|||
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
import { matchSourceIds } from '../match-source-ids';
|
||||
|
||||
export interface WorkflowIdConflict {
|
||||
sourceWorkflowId: string;
|
||||
existingWorkflowId: string;
|
||||
|
|
@ -61,13 +63,11 @@ export class WorkflowImportMatchService {
|
|||
return { matches: new Map(), lineageConflicts: [] };
|
||||
}
|
||||
|
||||
const packageWorkflowIds = new Set(sourceWorkflowIds);
|
||||
const finderOptions = {
|
||||
includeActiveVersion: true,
|
||||
includeParentFolder: true,
|
||||
includeArchived: true,
|
||||
} as const;
|
||||
const matchBySourceWorkflowId = new Map<string, WorkflowEntity>();
|
||||
const lineageConflicts: WorkflowLineageConflict[] = [];
|
||||
|
||||
const workflows = await this.workflowFinderService.findOwnedWorkflowsBySourceWorkflowIds(
|
||||
|
|
@ -76,32 +76,25 @@ export class WorkflowImportMatchService {
|
|||
finderOptions,
|
||||
);
|
||||
|
||||
const workflowsBySourceId = new Map<string, WorkflowEntity[]>();
|
||||
for (const workflow of workflows) {
|
||||
if (!workflow.sourceWorkflowId) continue;
|
||||
const { matches, ambiguous } = matchSourceIds(
|
||||
sourceWorkflowIds,
|
||||
workflows,
|
||||
(workflow) => workflow.sourceWorkflowId,
|
||||
);
|
||||
|
||||
const key = workflow.sourceWorkflowId;
|
||||
if (!packageWorkflowIds.has(key)) continue;
|
||||
const candidates = workflowsBySourceId.get(key) ?? [];
|
||||
candidates.push(workflow);
|
||||
workflowsBySourceId.set(key, candidates);
|
||||
}
|
||||
|
||||
for (const [sourceWorkflowId, candidates] of workflowsBySourceId) {
|
||||
for (const [sourceWorkflowId, candidates] of ambiguous) {
|
||||
const activeCandidates = candidates.filter((workflow) => !workflow.isArchived);
|
||||
if (candidates.length === 1 || activeCandidates.length === 1) {
|
||||
matchBySourceWorkflowId.set(sourceWorkflowId, activeCandidates[0] ?? candidates[0]);
|
||||
if (activeCandidates.length === 1) {
|
||||
matches.set(sourceWorkflowId, activeCandidates[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sortedCandidates = candidates.toSorted((left, right) =>
|
||||
left.id.localeCompare(right.id),
|
||||
);
|
||||
matchBySourceWorkflowId.set(sourceWorkflowId, sortedCandidates[0]);
|
||||
// Keep a representative so the planner skips the conflicting import.
|
||||
matches.set(sourceWorkflowId, candidates[0]);
|
||||
lineageConflicts.push({
|
||||
sourceWorkflowId,
|
||||
projectId,
|
||||
existingWorkflows: sortedCandidates.map(({ id, name, isArchived }) => ({
|
||||
existingWorkflows: candidates.map(({ id, name, isArchived }) => ({
|
||||
id,
|
||||
name,
|
||||
isArchived,
|
||||
|
|
@ -109,15 +102,6 @@ export class WorkflowImportMatchService {
|
|||
});
|
||||
}
|
||||
|
||||
for (const workflow of workflows) {
|
||||
if (workflow.sourceWorkflowId !== null) continue;
|
||||
|
||||
const key = workflow.id;
|
||||
if (!packageWorkflowIds.has(key) || matchBySourceWorkflowId.has(key)) continue;
|
||||
|
||||
matchBySourceWorkflowId.set(key, workflow);
|
||||
}
|
||||
|
||||
return { matches: matchBySourceWorkflowId, lineageConflicts };
|
||||
return { matches, lineageConflicts };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
type WorkflowArchiveTransition,
|
||||
} from './workflow-archive-transition';
|
||||
import { decideWorkflowConflictAction } from './workflow-conflict-policy';
|
||||
import { decideWorkflowId } from './workflow-id-policy';
|
||||
import {
|
||||
WorkflowImportMatchService,
|
||||
type WorkflowIdConflict,
|
||||
|
|
@ -40,6 +39,7 @@ import type {
|
|||
WorkflowIdPolicy,
|
||||
} from '../../n8n-packages.types';
|
||||
import { visitWorkflowCredentials } from '../credential/workflow-credential-references';
|
||||
import { decideImportId } from '../import-id-policy';
|
||||
|
||||
export interface WorkflowImportResult {
|
||||
outcomes: PersistedWorkflowOutcome[];
|
||||
|
|
@ -388,7 +388,7 @@ function toPlanItem(
|
|||
if (existing === null) {
|
||||
return {
|
||||
action: 'create',
|
||||
decidedId: decideWorkflowId(idPolicy, prepared.sourceWorkflowId),
|
||||
decidedId: decideImportId(idPolicy, prepared.sourceWorkflowId),
|
||||
...prepared,
|
||||
};
|
||||
}
|
||||
|
|
@ -411,7 +411,7 @@ function toPlanItem(
|
|||
// Only `fail` reaches here with a match; it records a conflict the gate rejects first.
|
||||
return {
|
||||
action,
|
||||
decidedId: decideWorkflowId(idPolicy, prepared.sourceWorkflowId),
|
||||
decidedId: decideImportId(idPolicy, prepared.sourceWorkflowId),
|
||||
...prepared,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user