mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
feat(core): Add the Agent package export adapter (no-changelog) (#40402)
This commit is contained in:
@@ -8,7 +8,9 @@ import type { Agent } from '../entities/agent.entity';
|
||||
* column holds everything except triggers, which live on `agent.integrations`.
|
||||
* The builder LLM consumes the merged shape.
|
||||
*/
|
||||
export function composeJsonConfig(agent: Agent): AgentJsonConfig | null {
|
||||
export function composeJsonConfig(
|
||||
agent: Pick<Agent, 'schema' | 'integrations'>,
|
||||
): AgentJsonConfig | null {
|
||||
if (!agent.schema) return null;
|
||||
return {
|
||||
...agent.schema,
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
import { AgentJsonConfigSchema } from '@n8n/api-types';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
|
||||
import type { Project } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { ConflictError } from '@n8n/errors';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { Agent } from '@/modules/agents/entities/agent.entity';
|
||||
import { AgentHistoryRepository } from '@/modules/agents/repositories/agent-history.repository';
|
||||
import { AgentTaskSnapshotRepository } from '@/modules/agents/repositories/agent-task-snapshot.repository';
|
||||
import { AgentTaskRepository } from '@/modules/agents/repositories/agent-task.repository';
|
||||
import { AgentRepository } from '@/modules/agents/repositories/agent.repository';
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
|
||||
import { N8nPackageParser } from '../engine/n8n-package-parser';
|
||||
import { AgentExporter } from '../entities/agent/agent.exporter';
|
||||
import type { WorkflowSerializer } from '../entities/workflow/workflow.serializer';
|
||||
import { DirectoryPackageReader } from '../io/directory/directory-package-reader';
|
||||
import { DirectoryPackageWriter } from '../io/directory/directory-package-writer';
|
||||
import { TarPackageReader } from '../io/tar/tar-package-reader';
|
||||
import { TarPackageWriter } from '../io/tar/tar-package-writer';
|
||||
import { PackageImportConfig } from '../n8n-packages.config';
|
||||
import { WorkflowVersionPolicy } from '../n8n-packages.types';
|
||||
import type { ManifestEntry, PackageManifest } from '../spec/manifest.schema';
|
||||
import { serializedAgentSchema, type SerializedAgent } from '../spec/serialized/agent.schema';
|
||||
import { looseAgentsFixture } from './fixtures/agent-package-fixtures';
|
||||
import { streamToBuffer } from './utils/tar-support';
|
||||
|
||||
const parser = new N8nPackageParser(mock<Logger>(), mock<NodeTypes>(), mock<WorkflowSerializer>());
|
||||
const limits = new PackageImportConfig();
|
||||
const sources: Array<{ agent: Agent; draft: SerializedAgent; published: SerializedAgent }> = [];
|
||||
let project: Project;
|
||||
let exporter: AgentExporter;
|
||||
let agentRepository: AgentRepository;
|
||||
let historyRepository: AgentHistoryRepository;
|
||||
let taskRepository: AgentTaskRepository;
|
||||
let taskSnapshotRepository: AgentTaskSnapshotRepository;
|
||||
let directory: string;
|
||||
|
||||
async function readSourceRecords() {
|
||||
return await Promise.all([
|
||||
agentRepository.find({ order: { id: 'ASC' } }),
|
||||
historyRepository.find({ order: { versionId: 'ASC' } }),
|
||||
taskRepository.find({ order: { id: 'ASC' } }),
|
||||
taskSnapshotRepository.find({ order: { versionId: 'ASC', taskId: 'ASC' } }),
|
||||
]);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await testModules.loadModules(['agents']);
|
||||
await testDb.init();
|
||||
agentRepository = Container.get(AgentRepository);
|
||||
historyRepository = Container.get(AgentHistoryRepository);
|
||||
taskRepository = Container.get(AgentTaskRepository);
|
||||
taskSnapshotRepository = Container.get(AgentTaskSnapshotRepository);
|
||||
exporter = Container.get(AgentExporter);
|
||||
project = await createTeamProject('Operations');
|
||||
const fixture = looseAgentsFixture();
|
||||
for (const entry of fixture.manifest.agents) {
|
||||
const draft = serializedAgentSchema.parse(fixture.files[`${entry.target}/agent.json`]);
|
||||
const published = structuredClone(draft);
|
||||
published.config = {
|
||||
...AgentJsonConfigSchema.parse(published.config),
|
||||
instructions: 'Published configuration',
|
||||
integrations: [{ type: 'n8n_chat', credentialId: '' }],
|
||||
};
|
||||
published.skills['shared-skill'].instructions = 'Published skill';
|
||||
published.tools.shared_tool.code = 'throw new Error("Published tool must not run");';
|
||||
published.tasks[`${entry.id}_task`] = {
|
||||
name: 'Published schedule',
|
||||
objective: 'Published objective',
|
||||
cronExpression: '0 12 * * 1',
|
||||
timezone: 'Europe/Vienna',
|
||||
};
|
||||
const agent = await agentRepository.save({
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
projectId: project.id,
|
||||
schema: draft.config,
|
||||
skills: draft.skills,
|
||||
tools: draft.tools,
|
||||
availableInMCP: draft.availableInMCP,
|
||||
integrations: [{ type: 'slack', credentialId: 'current-slack' }],
|
||||
versionId: `${draft.id}_draft`,
|
||||
activeVersionId: null,
|
||||
revision: 0,
|
||||
});
|
||||
await taskRepository.save(
|
||||
Object.entries(draft.tasks).map(([id, task]) => ({ id, agentId: agent.id, ...task })),
|
||||
);
|
||||
const history = await historyRepository.saveVersion({
|
||||
versionId: `${agent.id}_published`,
|
||||
agentId: agent.id,
|
||||
schema: published.config,
|
||||
skills: published.skills,
|
||||
tools: published.tools,
|
||||
publishedBy: 'Package test',
|
||||
});
|
||||
await taskSnapshotRepository.saveForVersion(
|
||||
Object.entries(published.tasks).map(([taskId, task]) => ({
|
||||
versionId: history.versionId,
|
||||
taskId,
|
||||
enabled: false,
|
||||
...task,
|
||||
})),
|
||||
);
|
||||
await agentRepository.setActiveVersionFenced(agent.id, agent.revision, {
|
||||
activeVersionId: history.versionId,
|
||||
versionId: agent.versionId!,
|
||||
});
|
||||
sources.push({ agent: (await agentRepository.findById(agent.id))!, draft, published });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'n8n-agent-export-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ prefix: '', policy: WorkflowVersionPolicy.Latest, selected: 'draft' },
|
||||
{ prefix: '', policy: WorkflowVersionPolicy.PublishedStrict, selected: 'published' },
|
||||
{ prefix: 'projects/operations', policy: WorkflowVersionPolicy.Latest, selected: 'draft' },
|
||||
{
|
||||
prefix: 'projects/operations',
|
||||
policy: WorkflowVersionPolicy.PublishedStrict,
|
||||
selected: 'published',
|
||||
},
|
||||
] as const)(
|
||||
'round-trips $selected definitions at "$prefix" without changing source records',
|
||||
async ({ prefix, policy, selected }) => {
|
||||
const before = await readSourceRecords();
|
||||
const archive = new TarPackageWriter();
|
||||
const loose = new DirectoryPackageWriter(directory);
|
||||
const entries: ManifestEntry[] = [];
|
||||
for (const { agent } of sources) {
|
||||
const snapshot = (await exporter.prepare(agent, policy))!;
|
||||
expect(snapshot.projectId).toBe(project.id);
|
||||
const entry = await exporter.write(snapshot, archive, prefix);
|
||||
expect(await exporter.write(snapshot, loose, prefix)).toEqual(entry);
|
||||
entries.push(entry);
|
||||
}
|
||||
const manifest: PackageManifest = {
|
||||
...looseAgentsFixture().manifest,
|
||||
agents: entries,
|
||||
requirements: undefined,
|
||||
projects: prefix ? [{ id: project.id, name: project.name, target: prefix }] : undefined,
|
||||
};
|
||||
for (const writer of [archive, loose]) {
|
||||
await writer.writeFile('manifest.json', JSON.stringify(manifest));
|
||||
if (prefix)
|
||||
await writer.writeFile(
|
||||
`${prefix}/project.json`,
|
||||
JSON.stringify({ id: project.id, name: project.name }),
|
||||
);
|
||||
}
|
||||
const archiveReader = new TarPackageReader(await streamToBuffer(archive.finalize()), limits);
|
||||
const directoryReader = new DirectoryPackageReader(directory, limits);
|
||||
await directoryReader.listEntries();
|
||||
const basePrefix = prefix ? `${prefix}/` : '';
|
||||
const parsed = await parser.getAgents(archiveReader, basePrefix);
|
||||
expect(await parser.getAgents(directoryReader, basePrefix)).toEqual(parsed);
|
||||
expect(parsed).toHaveLength(2);
|
||||
for (const [index, source] of sources.entries()) {
|
||||
const { id, ...content } = source[selected];
|
||||
const integrations = [{ type: 'slack', credentialId: 'current-slack' }];
|
||||
if (selected === 'published') integrations.push({ type: 'n8n_chat', credentialId: '' });
|
||||
expect(parsed[index]).toEqual({
|
||||
...content,
|
||||
sourceAgentId: id,
|
||||
config: { ...content.config, integrations },
|
||||
metadata: {
|
||||
versionId: selected === 'draft' ? source.agent.versionId : source.agent.activeVersionId,
|
||||
publishedVersionId: source.agent.activeVersionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(await readSourceRecords()).toEqual(before);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a stale draft through the definition service', async () => {
|
||||
const agent = sources[0].agent;
|
||||
await expect(exporter.prepare({ ...agent, revision: agent.revision - 1 })).rejects.toThrow(
|
||||
ConflictError,
|
||||
);
|
||||
});
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
AI_GATEWAY_MANAGED_TAG,
|
||||
MANAGED_CREDENTIAL_TOKEN,
|
||||
type AgentJsonConfig,
|
||||
type AgentJsonNodeToolConfig,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
import { looseAgentsFixture } from '../../../__tests__/fixtures/agent-package-fixtures';
|
||||
import { serializedAgentSchema } from '../../../spec/serialized/agent.schema';
|
||||
import { CredentialRequirementsExtractor } from '../../credential/credential-requirements.extractor';
|
||||
import type { PreparedAgentExport } from '../agent-export.types';
|
||||
import { AgentRequirementsExtractor } from '../agent-requirements.extractor';
|
||||
import { AgentSerializer } from '../agent.serializer';
|
||||
|
||||
const extractor = new AgentRequirementsExtractor(new CredentialRequirementsExtractor());
|
||||
|
||||
function prepare(config: AgentJsonConfig | null): PreparedAgentExport {
|
||||
const source = serializedAgentSchema.parse(
|
||||
looseAgentsFixture().files['agents/support/agent.json'],
|
||||
);
|
||||
return {
|
||||
projectId: 'source-project',
|
||||
content: new AgentSerializer().serialize(
|
||||
source,
|
||||
{
|
||||
schema: config,
|
||||
skills: source.skills,
|
||||
tools: source.tools,
|
||||
tasks: new Map(Object.entries(source.tasks)),
|
||||
},
|
||||
config?.integrations ?? [],
|
||||
),
|
||||
metadata: { versionId: null, publishedVersionId: null },
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentRequirementsExtractor', () => {
|
||||
it('attributes references, deduplicates credentials, and keeps unresolved tool definitions', () => {
|
||||
const nodeTool: AgentJsonNodeToolConfig = {
|
||||
type: 'node',
|
||||
name: 'Fetch',
|
||||
enabled: false,
|
||||
node: {
|
||||
nodeType: 'n8n-nodes-base.httpRequest',
|
||||
nodeTypeVersion: 4,
|
||||
nodeParameters: {
|
||||
credentialId: 'opaque-node-input',
|
||||
workflowId: 'opaque-workflow',
|
||||
expression: '={{ $vars.TEST }}',
|
||||
},
|
||||
credentials: {
|
||||
httpHeaderAuth: { id: 'shared-credential', name: 'Header account' },
|
||||
httpBasicAuth: { id: 'typed-credential', name: '' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const workflowTools: NonNullable<AgentJsonConfig['tools']> = [
|
||||
{ type: 'workflow', workflow: 'Lookup', workflowId: 'lookup-id', enabled: false },
|
||||
{
|
||||
type: 'workflow',
|
||||
workflow: 'Resolve by name',
|
||||
inputs: { data: { mode: 'fixed', value: { credential: 'opaque-workflow-input' } } },
|
||||
},
|
||||
];
|
||||
const snapshot = prepare({
|
||||
name: 'Support',
|
||||
model: 'openai/gpt-4o',
|
||||
credential: 'model-credential',
|
||||
instructions: '',
|
||||
integrations: [{ type: 'slack', credentialId: 'shared-credential' }],
|
||||
tools: [...workflowTools, nodeTool, { ...nodeTool, name: 'Fetch again' }],
|
||||
subAgents: { agents: [{ agentId: 'child', enabled: false }, { agentId: 'child' }] },
|
||||
providerTools: { search: { credentialId: 'opaque-provider-input' } },
|
||||
});
|
||||
const before = structuredClone(snapshot);
|
||||
|
||||
expect(extractor.extract(snapshot)).toEqual({
|
||||
agentId: 'support_source',
|
||||
projectId: 'source-project',
|
||||
credentials: [
|
||||
{ credentialId: 'model-credential' },
|
||||
{
|
||||
credentialId: 'shared-credential',
|
||||
credentialName: 'Header account',
|
||||
credentialType: 'httpHeaderAuth',
|
||||
},
|
||||
{ credentialId: 'typed-credential', credentialType: 'httpBasicAuth' },
|
||||
],
|
||||
workflowTools,
|
||||
agentIds: ['child'],
|
||||
nodeTools: [nodeTool, { ...nodeTool, name: 'Fetch again' }],
|
||||
});
|
||||
expect(snapshot).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([AI_GATEWAY_MANAGED_TAG, MANAGED_CREDENTIAL_TOKEN, ''])(
|
||||
'preserves marker %j in the package and excludes it from credential requirements',
|
||||
(credential) => {
|
||||
const snapshot = prepare({
|
||||
name: 'Managed model',
|
||||
model: 'openai/gpt-4o',
|
||||
credential,
|
||||
instructions: '',
|
||||
tools: [
|
||||
{
|
||||
type: 'node',
|
||||
name: 'Model',
|
||||
node: {
|
||||
nodeType: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
nodeTypeVersion: 1,
|
||||
nodeParameters: {},
|
||||
credentials: { openAiApi: { id: null, name: '', __aiGatewayManaged: true } },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(extractor.extract(snapshot).credentials).toEqual([]);
|
||||
expect(snapshot.content.config).toMatchObject({
|
||||
credential,
|
||||
tools: [
|
||||
{
|
||||
node: { credentials: { openAiApi: { id: null, name: '', __aiGatewayManaged: true } } },
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('returns no references for a null configuration', () => {
|
||||
expect(extractor.extract(prepare(null))).toEqual({
|
||||
agentId: 'support_source',
|
||||
projectId: 'source-project',
|
||||
credentials: [],
|
||||
workflowTools: [],
|
||||
agentIds: [],
|
||||
nodeTools: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
import { AgentJsonConfigSchema } from '@n8n/api-types';
|
||||
import { ConflictError } from '@n8n/errors';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import type { AgentDefinitionService } from '@/modules/agents/agent-definition.service';
|
||||
import type { AgentHistory } from '@/modules/agents/entities/agent-history.entity';
|
||||
import { Agent } from '@/modules/agents/entities/agent.entity';
|
||||
import type { AgentDefinition } from '@/modules/agents/utils/agent-definition';
|
||||
|
||||
import { looseAgentsFixture } from '../../../__tests__/fixtures/agent-package-fixtures';
|
||||
import { CapturingWriter } from '../../../io/__tests__/utils/capturing-writer';
|
||||
import { HashingPackageWriter } from '../../../io/hashing-package-writer';
|
||||
import { WorkflowVersionPolicy } from '../../../n8n-packages.types';
|
||||
import { serializedAgentSchema } from '../../../spec/serialized/agent.schema';
|
||||
import { AgentExporter } from '../agent.exporter';
|
||||
import { AgentSerializer } from '../agent.serializer';
|
||||
|
||||
const taskId = 'support_source_task';
|
||||
|
||||
function definitionFixture(version: string): AgentDefinition {
|
||||
const fixture = serializedAgentSchema.parse(
|
||||
looseAgentsFixture().files['agents/support/agent.json'],
|
||||
);
|
||||
const config = AgentJsonConfigSchema.parse(fixture.config);
|
||||
config.instructions = `${version} instructions`;
|
||||
fixture.skills['shared-skill'].instructions = `${version} skill`;
|
||||
fixture.tools.shared_tool.code = `throw new Error("${version} tool must not run");`;
|
||||
fixture.tasks[taskId].objective = `${version} task`;
|
||||
return {
|
||||
schema: config,
|
||||
skills: fixture.skills,
|
||||
tools: fixture.tools,
|
||||
tasks: new Map(Object.entries(fixture.tasks)),
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const draft = definitionFixture('Draft');
|
||||
const published = definitionFixture('Published');
|
||||
published.schema = {
|
||||
...AgentJsonConfigSchema.parse(published.schema),
|
||||
integrations: [
|
||||
{ type: 'n8n_chat', credentialId: '' },
|
||||
{ type: 'slack', credentialId: 'old-slack' },
|
||||
],
|
||||
};
|
||||
const agent: Agent = Object.assign(new Agent(), {
|
||||
id: 'support_source',
|
||||
name: 'Current name',
|
||||
projectId: 'source-project',
|
||||
availableInMCP: true,
|
||||
versionId: 'draft-version',
|
||||
activeVersionId: 'published-version',
|
||||
activeVersion: mock<AgentHistory>({ versionId: 'published-version' }),
|
||||
integrations: [{ type: 'slack', credentialId: 'current-slack' }],
|
||||
schema: draft.schema,
|
||||
skills: draft.skills,
|
||||
tools: draft.tools,
|
||||
});
|
||||
const definitions = mock<AgentDefinitionService>();
|
||||
definitions.readDraft.mockResolvedValue(draft);
|
||||
definitions.readVersion.mockResolvedValue(published);
|
||||
return {
|
||||
agent,
|
||||
draft,
|
||||
published,
|
||||
definitions,
|
||||
exporter: new AgentExporter(definitions, new AgentSerializer()),
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentExporter', () => {
|
||||
it.each([
|
||||
{ policy: undefined, published: true, selected: 'draft' },
|
||||
{ policy: WorkflowVersionPolicy.Latest, published: false, selected: 'draft' },
|
||||
{ policy: WorkflowVersionPolicy.PublishedStrict, published: true, selected: 'published' },
|
||||
{ policy: WorkflowVersionPolicy.PreferPublished, published: true, selected: 'published' },
|
||||
{ policy: WorkflowVersionPolicy.PreferPublished, published: false, selected: 'draft' },
|
||||
{ policy: WorkflowVersionPolicy.IgnoreUnpublished, published: true, selected: 'published' },
|
||||
] as const)(
|
||||
'prepares $selected for policy $policy with published=$published',
|
||||
async (testCase) => {
|
||||
const source = setup();
|
||||
if (!testCase.published) source.agent.activeVersionId = null;
|
||||
|
||||
const snapshot = await source.exporter.prepare(source.agent, testCase.policy);
|
||||
const selected = source[testCase.selected];
|
||||
const integrations = [{ type: 'slack', credentialId: 'current-slack' }];
|
||||
if (testCase.selected === 'published')
|
||||
integrations.push({ type: 'n8n_chat', credentialId: '' });
|
||||
expect(snapshot).toEqual({
|
||||
projectId: 'source-project',
|
||||
content: {
|
||||
id: 'support_source',
|
||||
name: 'Current name',
|
||||
availableInMCP: true,
|
||||
config: { ...selected.schema, integrations },
|
||||
skills: selected.skills,
|
||||
tools: selected.tools,
|
||||
tasks: { [taskId]: selected.tasks.get(taskId) },
|
||||
},
|
||||
metadata: {
|
||||
versionId: `${testCase.selected}-version`,
|
||||
publishedVersionId: testCase.published ? 'published-version' : null,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('skips an unpublished Agent under ignore-unpublished', async () => {
|
||||
const { agent, exporter } = setup();
|
||||
agent.activeVersionId = null;
|
||||
await expect(
|
||||
exporter.prepare(agent, WorkflowVersionPolicy.IgnoreUnpublished),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects an unpublished Agent under published-strict', async () => {
|
||||
const { agent, exporter } = setup();
|
||||
agent.activeVersionId = null;
|
||||
await expect(exporter.prepare(agent, WorkflowVersionPolicy.PublishedStrict)).rejects.toThrow(
|
||||
'Agent "support_source" has no published version',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when the selected published snapshot is missing', async () => {
|
||||
const { agent, exporter } = setup();
|
||||
agent.activeVersion = null;
|
||||
await expect(exporter.prepare(agent, WorkflowVersionPolicy.PreferPublished)).rejects.toThrow(
|
||||
'Published version was not loaded for Agent',
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates draft revision conflicts', async () => {
|
||||
const { agent, definitions, exporter } = setup();
|
||||
const conflict = new ConflictError('Agent was modified concurrently; please retry');
|
||||
definitions.readDraft.mockRejectedValue(conflict);
|
||||
await expect(exporter.prepare(agent)).rejects.toBe(conflict);
|
||||
});
|
||||
|
||||
it('does not add current Chat settings to a published definition without Chat', async () => {
|
||||
const { agent, published, exporter } = setup();
|
||||
agent.integrations.push({ type: 'n8n_chat', credentialId: '' });
|
||||
published.schema!.integrations = [];
|
||||
const snapshot = await exporter.prepare(agent, WorkflowVersionPolicy.PublishedStrict);
|
||||
expect(snapshot?.content.config?.integrations).toEqual([
|
||||
{ type: 'slack', credentialId: 'current-slack' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
kind: 'skill',
|
||||
id: 'shared-skill',
|
||||
remove: (definition: AgentDefinition) => {
|
||||
definition.skills = {};
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'custom tool',
|
||||
id: 'shared_tool',
|
||||
remove: (definition: AgentDefinition) => {
|
||||
definition.tools = {};
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'task',
|
||||
id: taskId,
|
||||
remove: (definition: AgentDefinition) => {
|
||||
definition.tasks = new Map();
|
||||
},
|
||||
},
|
||||
])('requires a body for a disabled $kind reference', async ({ kind, id, remove }) => {
|
||||
const { agent, draft, exporter } = setup();
|
||||
remove(draft);
|
||||
await expect(exporter.prepare(agent)).rejects.toThrow(
|
||||
`Agent "support_source" references missing ${kind} "${id}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
kind: 'skill',
|
||||
corrupt: (definition: AgentDefinition) =>
|
||||
Object.assign(definition.skills['shared-skill'], { instructions: 42 }),
|
||||
},
|
||||
{
|
||||
kind: 'tool descriptor',
|
||||
corrupt: (definition: AgentDefinition) =>
|
||||
Object.assign(definition.tools.shared_tool.descriptor, { hasResume: 'yes' }),
|
||||
},
|
||||
{
|
||||
kind: 'task',
|
||||
corrupt: (definition: AgentDefinition) =>
|
||||
Object.assign(definition.tasks.get(taskId)!, { timezone: 'invalid-zone' }),
|
||||
},
|
||||
])('rejects an invalid $kind before writing', async ({ corrupt }) => {
|
||||
const { agent, draft, exporter } = setup();
|
||||
corrupt(draft);
|
||||
await expect(exporter.prepare(agent)).rejects.toThrow(ZodError);
|
||||
});
|
||||
|
||||
it('omits unused bodies and normalizes an omitted timezone', async () => {
|
||||
const { agent, draft, exporter } = setup();
|
||||
draft.skills.unused = { ...draft.skills['shared-skill'] };
|
||||
draft.tools.unused = { ...draft.tools.shared_tool };
|
||||
const task = draft.tasks.get(taskId)!;
|
||||
delete task.timezone;
|
||||
draft.tasks = new Map([
|
||||
[taskId, task],
|
||||
['unused', { ...task }],
|
||||
]);
|
||||
const snapshot = await exporter.prepare(agent);
|
||||
expect(Object.keys(snapshot!.content.skills)).toEqual(['shared-skill']);
|
||||
expect(Object.keys(snapshot!.content.tools)).toEqual(['shared_tool']);
|
||||
expect(snapshot?.content.tasks).toEqual({ [taskId]: { ...task, timezone: null } });
|
||||
});
|
||||
|
||||
it('exports empty body maps for a null configuration', async () => {
|
||||
const { agent, draft, exporter } = setup();
|
||||
draft.schema = null;
|
||||
expect((await exporter.prepare(agent))?.content).toEqual({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
availableInMCP: true,
|
||||
config: null,
|
||||
skills: {},
|
||||
tools: {},
|
||||
tasks: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('writes the detached snapshot after source changes', async () => {
|
||||
const { agent, draft, definitions, exporter } = setup();
|
||||
const snapshot = (await exporter.prepare(agent))!;
|
||||
const expected = structuredClone(snapshot);
|
||||
draft.schema!.instructions = 'Changed after preparation';
|
||||
draft.skills['shared-skill'].references![0].content = 'Changed reference';
|
||||
Object.assign(draft.tools.shared_tool.descriptor.inputSchema!, { properties: {} });
|
||||
Object.assign(draft.tools.shared_tool.descriptor.providerOptions!, {
|
||||
provider: { cache: true },
|
||||
});
|
||||
draft.tasks.get(taskId)!.objective = 'Changed task';
|
||||
agent.integrations[0].credentialId = 'changed-credential';
|
||||
agent.name = 'Changed name';
|
||||
definitions.readDraft.mockRejectedValue(new Error('Do not reload the definition'));
|
||||
const writer = new CapturingWriter();
|
||||
const entry = await exporter.write(snapshot, writer);
|
||||
|
||||
expect(snapshot).toEqual(expected);
|
||||
expect(entry).toEqual({
|
||||
id: 'support_source',
|
||||
name: 'Current name',
|
||||
target: 'agents/current-name-support_source',
|
||||
});
|
||||
expect(writer.files).toHaveLength(2);
|
||||
expect(JSON.parse(writer.files[0].content)).toEqual(expected.content);
|
||||
expect(JSON.parse(writer.files[1].content)).toEqual(expected.metadata);
|
||||
});
|
||||
|
||||
it('keeps authored bytes and hashes stable when only publication metadata changes', async () => {
|
||||
const { agent, exporter } = setup();
|
||||
const before = (await exporter.prepare(agent))!;
|
||||
agent.versionId = 'new-draft-version';
|
||||
agent.activeVersionId = null;
|
||||
const after = (await exporter.prepare(agent))!;
|
||||
const captures = [new CapturingWriter(), new CapturingWriter()];
|
||||
const hashes = [new HashingPackageWriter(), new HashingPackageWriter()];
|
||||
for (const [index, snapshot] of [before, after].entries()) {
|
||||
await exporter.write(snapshot, captures[index]);
|
||||
await exporter.write(snapshot, hashes[index]);
|
||||
}
|
||||
expect(captures[0].files[0]).toEqual(captures[1].files[0]);
|
||||
expect(hashes[0].finalize()[0]).toEqual(hashes[1].finalize()[0]);
|
||||
expect(captures[0].files[1]).not.toEqual(captures[1].files[1]);
|
||||
expect(hashes[0].finalize()[1]).not.toEqual(hashes[1].finalize()[1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AgentJsonNodeToolConfig, AgentJsonWorkflowToolConfig } from '@n8n/api-types';
|
||||
|
||||
import type { SerializedAgent, SerializedAgentMetadata } from '../../spec/serialized/agent.schema';
|
||||
import type { AgentCredentialRequirement } from '../credential/credential.types';
|
||||
|
||||
export interface PreparedAgentExport {
|
||||
projectId: string;
|
||||
content: SerializedAgent;
|
||||
metadata: SerializedAgentMetadata;
|
||||
}
|
||||
|
||||
export interface AgentExportRequirements {
|
||||
agentId: string;
|
||||
projectId: string;
|
||||
credentials: AgentCredentialRequirement[];
|
||||
workflowTools: AgentJsonWorkflowToolConfig[];
|
||||
agentIds: string[];
|
||||
nodeTools: AgentJsonNodeToolConfig[];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { extractAgentWorkflowRefs } from '@/modules/agents/utils/extract-agent-workflow-refs';
|
||||
|
||||
import type { AgentExportRequirements, PreparedAgentExport } from './agent-export.types';
|
||||
import { CredentialRequirementsExtractor } from '../credential/credential-requirements.extractor';
|
||||
|
||||
@Service()
|
||||
export class AgentRequirementsExtractor {
|
||||
constructor(private readonly credentialRequirementsExtractor: CredentialRequirementsExtractor) {}
|
||||
|
||||
extract({ content, projectId }: PreparedAgentExport): AgentExportRequirements {
|
||||
const config = content.config;
|
||||
const nodeTools = (config?.tools ?? []).filter((tool) => tool.type === 'node');
|
||||
return {
|
||||
agentId: content.id,
|
||||
projectId,
|
||||
credentials: this.credentialRequirementsExtractor.extractFromAgent(config),
|
||||
workflowTools: extractAgentWorkflowRefs(config),
|
||||
agentIds: [...new Set((config?.subAgents?.agents ?? []).map(({ agentId }) => agentId))],
|
||||
nodeTools,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { isCredentialAgentIntegration, N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types';
|
||||
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 { AgentDefinition } from '@/modules/agents/utils/agent-definition';
|
||||
|
||||
import type { PreparedAgentExport } from './agent-export.types';
|
||||
import { AgentSerializer } from './agent.serializer';
|
||||
import { formatEntityFile } from '../../io/entity-file-format';
|
||||
import {
|
||||
agentMetadataFilePath,
|
||||
packageDirectory,
|
||||
writeManifestEntry,
|
||||
} from '../../io/manifest-entry';
|
||||
import type { PackageWriter } from '../../io/package-writer';
|
||||
import { WorkflowVersionPolicy } from '../../n8n-packages.types';
|
||||
import type { ManifestEntry } from '../../spec/manifest.schema';
|
||||
import { PackageExportBlockedError } from '../package-export.errors';
|
||||
|
||||
@Service()
|
||||
export class AgentExporter {
|
||||
constructor(
|
||||
private readonly definitionService: AgentDefinitionService,
|
||||
private readonly serializer: AgentSerializer,
|
||||
) {}
|
||||
|
||||
async prepare(
|
||||
agent: Agent,
|
||||
policy: WorkflowVersionPolicy = WorkflowVersionPolicy.Latest,
|
||||
): Promise<PreparedAgentExport | undefined> {
|
||||
if (agent.activeVersionId === null) {
|
||||
if (policy === WorkflowVersionPolicy.IgnoreUnpublished) return undefined;
|
||||
if (policy === WorkflowVersionPolicy.PublishedStrict) {
|
||||
throw new PackageExportBlockedError(
|
||||
`Agent "${agent.id}" has no published version. Export aborted.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let definition: AgentDefinition;
|
||||
let versionId = agent.versionId;
|
||||
let integrations = agent.integrations ?? [];
|
||||
if (policy !== WorkflowVersionPolicy.Latest && agent.activeVersionId !== null) {
|
||||
const version = agent.activeVersion;
|
||||
if (!version) {
|
||||
throw new UnexpectedError('Published version was not loaded for Agent', {
|
||||
extra: { agentId: agent.id, activeVersionId: agent.activeVersionId },
|
||||
});
|
||||
}
|
||||
definition = await this.definitionService.readVersion(version);
|
||||
versionId = version.versionId;
|
||||
integrations = [
|
||||
...integrations.filter(isCredentialAgentIntegration),
|
||||
...(definition.schema?.integrations ?? []).filter(
|
||||
({ type }) => type === N8N_CHAT_INTEGRATION_TYPE,
|
||||
),
|
||||
];
|
||||
} else {
|
||||
definition = await this.definitionService.readDraft(agent);
|
||||
}
|
||||
|
||||
const content = this.serializer.serialize(agent, definition, integrations);
|
||||
return {
|
||||
projectId: agent.projectId,
|
||||
content,
|
||||
metadata: this.serializer.serializeMetadata({
|
||||
versionId,
|
||||
activeVersionId: agent.activeVersionId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async write(
|
||||
snapshot: PreparedAgentExport,
|
||||
writer: PackageWriter,
|
||||
basePrefix = '',
|
||||
): Promise<ManifestEntry> {
|
||||
const entry = await writeManifestEntry(
|
||||
writer,
|
||||
'agents',
|
||||
packageDirectory('agents', basePrefix),
|
||||
snapshot.content,
|
||||
snapshot.content,
|
||||
);
|
||||
await writer.writeFile(
|
||||
agentMetadataFilePath(entry.target),
|
||||
formatEntityFile(snapshot.metadata),
|
||||
);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Service } from '@n8n/di';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { Agent } from '@/modules/agents/entities/agent.entity';
|
||||
import { composeJsonConfig } from '@/modules/agents/json-config/agent-config-composition';
|
||||
import type { AgentDefinition } from '@/modules/agents/utils/agent-definition';
|
||||
|
||||
import {
|
||||
serializedAgentMetadataSchema,
|
||||
serializedAgentSchema,
|
||||
type SerializedAgent,
|
||||
type SerializedAgentMetadata,
|
||||
} from '../../spec/serialized/agent.schema';
|
||||
import { PackageExportBlockedError } from '../package-export.errors';
|
||||
import { definePackageSerializationPayload } from '../package-serialization.types';
|
||||
|
||||
type AgentPackageKeyHandling = {
|
||||
id: 'copy';
|
||||
name: 'copy';
|
||||
schema: 'transform';
|
||||
integrations: 'transform';
|
||||
tools: 'transform';
|
||||
skills: 'transform';
|
||||
availableInMCP: 'copy';
|
||||
project: 'exclude';
|
||||
projectId: 'exclude';
|
||||
createdAt: 'exclude';
|
||||
updatedAt: 'exclude';
|
||||
versionId: 'exclude';
|
||||
activeVersionId: 'exclude';
|
||||
activeVersion: 'exclude';
|
||||
revision: 'exclude';
|
||||
setupCompletedAt: 'exclude';
|
||||
};
|
||||
|
||||
const serializePayload = definePackageSerializationPayload<
|
||||
Agent,
|
||||
z.input<typeof serializedAgentSchema>,
|
||||
AgentPackageKeyHandling
|
||||
>();
|
||||
|
||||
type AgentMetadataKeyHandling = Record<
|
||||
Exclude<keyof AgentPackageKeyHandling, 'versionId' | 'activeVersionId'>,
|
||||
'exclude'
|
||||
> & {
|
||||
versionId: 'copy';
|
||||
activeVersionId: 'transform';
|
||||
};
|
||||
|
||||
const serializeMetadataPayload = definePackageSerializationPayload<
|
||||
Agent,
|
||||
SerializedAgentMetadata,
|
||||
AgentMetadataKeyHandling
|
||||
>();
|
||||
|
||||
@Service()
|
||||
export class AgentSerializer {
|
||||
serialize(
|
||||
agent: Pick<Agent, 'id' | 'name' | 'availableInMCP'>,
|
||||
definition: AgentDefinition,
|
||||
integrations: Agent['integrations'],
|
||||
): SerializedAgent {
|
||||
const config = composeJsonConfig({ schema: definition.schema, integrations });
|
||||
const content = serializedAgentSchema.parse(
|
||||
serializePayload({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
config,
|
||||
availableInMCP: agent.availableInMCP,
|
||||
skills: this.referencedBodies(agent.id, 'skill', config?.skills ?? [], definition.skills),
|
||||
tools: this.referencedBodies(
|
||||
agent.id,
|
||||
'custom tool',
|
||||
config?.tools?.filter((tool) => tool.type === 'custom') ?? [],
|
||||
definition.tools,
|
||||
),
|
||||
tasks: this.referencedBodies(
|
||||
agent.id,
|
||||
'task',
|
||||
config?.tasks ?? [],
|
||||
Object.fromEntries(definition.tasks),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Tool JSON schemas and provider options can retain references after validation.
|
||||
return structuredClone(content);
|
||||
}
|
||||
|
||||
serializeMetadata(source: Pick<Agent, 'versionId' | 'activeVersionId'>): SerializedAgentMetadata {
|
||||
return serializedAgentMetadataSchema.parse(
|
||||
serializeMetadataPayload({
|
||||
versionId: source.versionId,
|
||||
publishedVersionId: source.activeVersionId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private referencedBodies<T>(
|
||||
agentId: string,
|
||||
kind: string,
|
||||
references: Array<{ id: string }>,
|
||||
bodies: Record<string, T>,
|
||||
): Record<string, T> {
|
||||
return Object.fromEntries(
|
||||
references.map(({ id }) => {
|
||||
const body = Object.hasOwn(bodies, id) ? bodies[id] : undefined;
|
||||
if (body === undefined) {
|
||||
throw new PackageExportBlockedError(
|
||||
`Agent "${agentId}" references missing ${kind} "${id}". Export aborted.`,
|
||||
);
|
||||
}
|
||||
return [id, body];
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -35,7 +35,7 @@ describe('CredentialRequirementsExtractor', () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(extractor.extract(workflow)).toEqual([]);
|
||||
expect(extractor.extractFromWorkflow(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits one requirement per node credential slot, keyed by credential type', () => {
|
||||
@@ -59,7 +59,7 @@ describe('CredentialRequirementsExtractor', () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(extractor.extract(workflow)).toEqual(
|
||||
expect(extractor.extractFromWorkflow(workflow)).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
workflowId: 'wf-creds',
|
||||
@@ -75,7 +75,7 @@ describe('CredentialRequirementsExtractor', () => {
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(extractor.extract(workflow)).toHaveLength(2);
|
||||
expect(extractor.extractFromWorkflow(workflow)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('dedupes when the same credential id appears in two nodes of one workflow', () => {
|
||||
@@ -107,7 +107,7 @@ describe('CredentialRequirementsExtractor', () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(extractor.extract(workflow)).toEqual([
|
||||
expect(extractor.extractFromWorkflow(workflow)).toEqual([
|
||||
{
|
||||
workflowId: 'wf-dup',
|
||||
credentialId: 'cred-shared',
|
||||
@@ -144,7 +144,7 @@ describe('CredentialRequirementsExtractor', () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(extractor.extract(workflow)).toEqual([
|
||||
expect(extractor.extractFromWorkflow(workflow)).toEqual([
|
||||
{
|
||||
workflowId: 'wf-inline',
|
||||
credentialId: 'cred-inline',
|
||||
@@ -172,7 +172,7 @@ describe('CredentialRequirementsExtractor', () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(extractor.extract(workflow)).toEqual([]);
|
||||
expect(extractor.extractFromWorkflow(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty list when the workflow has no nodes array at all', () => {
|
||||
@@ -180,6 +180,6 @@ describe('CredentialRequirementsExtractor', () => {
|
||||
// `nodes` may be absent rather than empty.
|
||||
const workflow = makeWorkflow({ id: 'wf-no-nodes', nodes: undefined as unknown as [] });
|
||||
|
||||
expect(extractor.extract(workflow)).toEqual([]);
|
||||
expect(extractor.extractFromWorkflow(workflow)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+22
-6
@@ -1,15 +1,15 @@
|
||||
import type { AgentJsonConfig } from '@n8n/api-types';
|
||||
import type { WorkflowEntity } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type { WorkflowCredentialRequirement } from './credential.types';
|
||||
import { extractAgentCredentialIds } from '@/modules/agents/utils/extract-agent-credential-ids';
|
||||
|
||||
import type { AgentCredentialRequirement, WorkflowCredentialRequirement } from './credential.types';
|
||||
import { visitWorkflowCredentials } from './workflow-credential-references';
|
||||
import type { RequirementsExtractor } from '../requirements-extractor';
|
||||
|
||||
@Service()
|
||||
export class CredentialRequirementsExtractor
|
||||
implements RequirementsExtractor<WorkflowCredentialRequirement>
|
||||
{
|
||||
extract(workflow: WorkflowEntity): WorkflowCredentialRequirement[] {
|
||||
export class CredentialRequirementsExtractor {
|
||||
extractFromWorkflow(workflow: WorkflowEntity): WorkflowCredentialRequirement[] {
|
||||
const byId = new Map<string, WorkflowCredentialRequirement>();
|
||||
|
||||
visitWorkflowCredentials(workflow.nodes, (credentialType, details) => {
|
||||
@@ -26,4 +26,20 @@ export class CredentialRequirementsExtractor
|
||||
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
extractFromAgent(config: AgentJsonConfig | null): AgentCredentialRequirement[] {
|
||||
const credentials = new Map<string, AgentCredentialRequirement>(
|
||||
[...extractAgentCredentialIds(config)].map((id) => [id, { credentialId: id }]),
|
||||
);
|
||||
const nodeCredentials = (config?.tools ?? [])
|
||||
.filter((tool) => tool.type === 'node')
|
||||
.flatMap(({ node }) => Object.entries(node.credentials ?? {}));
|
||||
for (const [type, details] of nodeCredentials) {
|
||||
const credential = details.id === null ? undefined : credentials.get(details.id);
|
||||
if (!credential) continue;
|
||||
if (details.name) credential.credentialName ??= details.name;
|
||||
if (type) credential.credentialType ??= type;
|
||||
}
|
||||
return [...credentials.values()];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ import type {
|
||||
} from '../../n8n-packages.types';
|
||||
import type { PackageCredentialRequirement } from '../../spec/requirements.schema';
|
||||
|
||||
export interface AgentCredentialRequirement {
|
||||
credentialId: string;
|
||||
credentialName?: string;
|
||||
credentialType?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowCredentialRequirement {
|
||||
workflowId: string;
|
||||
credentialId: string;
|
||||
|
||||
+3
-3
@@ -250,7 +250,7 @@ describe('AutoIncludedWorkflowExporter', () => {
|
||||
|
||||
it('extracts credential, data-table, and variable requirements from each workflow', async () => {
|
||||
const credentialExtractor = mock<CredentialRequirementsExtractor>();
|
||||
credentialExtractor.extract.mockReturnValue([
|
||||
credentialExtractor.extractFromWorkflow.mockReturnValue([
|
||||
{
|
||||
workflowId: 'wf_1',
|
||||
credentialId: 'cred-1',
|
||||
@@ -308,7 +308,7 @@ describe('AutoIncludedWorkflowExporter', () => {
|
||||
|
||||
it('does not extract requirements from a skipped (already-exported) workflow', async () => {
|
||||
const credentialExtractor = mock<CredentialRequirementsExtractor>();
|
||||
credentialExtractor.extract.mockReturnValue([]);
|
||||
credentialExtractor.extractFromWorkflow.mockReturnValue([]);
|
||||
const exporter = makeExporter(credentialExtractor);
|
||||
const writer = new CapturingWriter();
|
||||
const workflow = makeWorkflow({ id: 'wf_dup', name: 'Already Here' });
|
||||
@@ -320,7 +320,7 @@ describe('AutoIncludedWorkflowExporter', () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(credentialExtractor.extract).not.toHaveBeenCalled();
|
||||
expect(credentialExtractor.extractFromWorkflow).not.toHaveBeenCalled();
|
||||
expect(result.requirements.nodeTypes).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -330,7 +330,7 @@ describe('WorkflowExporter', () => {
|
||||
const a = makeWorkflow({ id: 'wf_a' });
|
||||
const b = makeWorkflow({ id: 'wf_b' });
|
||||
const extractor = mock<CredentialRequirementsExtractor>();
|
||||
extractor.extract.mockImplementation((workflow) => [
|
||||
extractor.extractFromWorkflow.mockImplementation((workflow) => [
|
||||
{
|
||||
workflowId: workflow.id,
|
||||
credentialId: `cred-from-${workflow.id}`,
|
||||
@@ -349,7 +349,7 @@ describe('WorkflowExporter', () => {
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(extractor.extract).toHaveBeenCalledTimes(2);
|
||||
expect(extractor.extractFromWorkflow).toHaveBeenCalledTimes(2);
|
||||
expect(requirements.credentials).toEqual<WorkflowCredentialRequirement[]>([
|
||||
{
|
||||
workflowId: 'wf_a',
|
||||
|
||||
+3
-1
@@ -110,7 +110,9 @@ export class AutoIncludedWorkflowExporter {
|
||||
);
|
||||
workflowEntries.push(entry);
|
||||
workflowEntriesById.set(entry.id, entry);
|
||||
credentials.push(...this.credentialRequirementsExtractor.extract(included.workflow));
|
||||
credentials.push(
|
||||
...this.credentialRequirementsExtractor.extractFromWorkflow(included.workflow),
|
||||
);
|
||||
dataTables.push(...this.dataTableRequirementsExtractor.extract(included.workflow));
|
||||
variables.push(...this.variableRequirementsExtractor.extract(included.workflow));
|
||||
tags.push(...this.tagRequirementsExtractor.extract(included.workflow));
|
||||
|
||||
@@ -93,7 +93,7 @@ export class WorkflowExporter {
|
||||
),
|
||||
);
|
||||
|
||||
credentials.push(...this.credentialRequirementsExtractor.extract(workflow));
|
||||
credentials.push(...this.credentialRequirementsExtractor.extractFromWorkflow(workflow));
|
||||
dataTables.push(...this.dataTableRequirementsExtractor.extract(workflow));
|
||||
variables.push(...this.variableRequirementsExtractor.extract(workflow));
|
||||
tags.push(...this.tagRequirementsExtractor.extract(workflow));
|
||||
|
||||
Reference in New Issue
Block a user