Compare commits

...

7 Commits
master ... beta

Author SHA1 Message Date
n8n-assistant[bot]
ade4ceace6
🚀 Release 2.25.3 (#31711)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-06-04 09:45:06 +00:00
n8n-assistant[bot]
4bfa3657df
fix(core): Prevent evaluation executions from stalling in status new (backport to release-candidate/2.25.x) (#31701)
Co-authored-by: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com>
2026-06-04 09:00:49 +00:00
n8n-assistant[bot]
ff5e4721c7
feat: Implements AI Assistant empty state workflow previews experiment (backport to release-candidate/2.25.x) (#31663)
Co-authored-by: Joco-95 <jonathan.codas@n8n.io>
2026-06-03 17:20:05 +02:00
n8n-assistant[bot]
aca2ddbce4
🚀 Release 2.25.2 (#31627)
Co-authored-by: konstantintieber <46342664+konstantintieber@users.noreply.github.com>
2026-06-03 08:37:45 +00:00
n8n-assistant[bot]
db938fc939
fix(core): External agent channels correctly utilise the user ID for episodic memory (backport to release-candidate/2.25.x) (#31589)
Co-authored-by: Michael Drury <me@michaeldrury.co.uk>
2026-06-02 17:48:12 +00:00
n8n-assistant[bot]
f98a1240a8
refactor(core): Rename N8N_OTEL_TRACES_PUBLISHED_ONLY env var to N8N_OTEL_TRACES_PRODUCTION_ONLY (backport to release-candidate/2.25.x) (#31579)
Co-authored-by: Dmitrii <dmitrii.kulikov@n8n.io>
2026-06-02 15:46:19 +00:00
n8n-assistant[bot]
1671eca21f
fix(core): The n8n import:workflow --activeState=fromJson cli can fail for subworkflow dependencies (backport to release-candidate/2.25.x) (#31569)
Co-authored-by: Konstantin Tieber <46342664+konstantintieber@users.noreply.github.com>
2026-06-02 13:36:51 +00:00
40 changed files with 3050 additions and 228 deletions

View File

@ -1,3 +1,25 @@
## [2.25.3](https://github.com/n8n-io/n8n/compare/n8n@2.25.2...n8n@2.25.3) (2026-06-04)
### Bug Fixes
* **core:** Prevent evaluation executions from stalling in status new ([#31701](https://github.com/n8n-io/n8n/issues/31701)) ([4bfa365](https://github.com/n8n-io/n8n/commit/4bfa3657df5fb7c004d6832597233e642ff0c97d))
### Features
* Implements AI Assistant empty state workflow previews experiment ([#31663](https://github.com/n8n-io/n8n/issues/31663)) ([ff5e472](https://github.com/n8n-io/n8n/commit/ff5e4721c73dc0ae00751ccbb4da43670e66e874))
## [2.25.2](https://github.com/n8n-io/n8n/compare/n8n@2.25.1...n8n@2.25.2) (2026-06-03)
### Bug Fixes
* **core:** External agent channels correctly utilise the user ID for episodic memory ([#31589](https://github.com/n8n-io/n8n/issues/31589)) ([db938fc](https://github.com/n8n-io/n8n/commit/db938fc9390f68f9b477b34a8bb01b1243a19456))
* **core:** The n8n import:workflow --activeState=fromJson cli can fail for subworkflow dependencies ([#31569](https://github.com/n8n-io/n8n/issues/31569)) ([1671eca](https://github.com/n8n-io/n8n/commit/1671eca21fc6ca3ca03dc908b16021eb3be3a7c0))
# [2.25.0](https://github.com/n8n-io/n8n/compare/n8n@2.24.0...n8n@2.25.0) (2026-06-02) # [2.25.0](https://github.com/n8n-io/n8n/compare/n8n@2.24.0...n8n@2.25.0) (2026-06-02)

View File

@ -1,6 +1,6 @@
{ {
"name": "n8n-monorepo", "name": "n8n-monorepo",
"version": "2.25.1", "version": "2.25.3",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=22.22", "node": ">=22.22",

View File

@ -1,6 +1,6 @@
{ {
"name": "n8n", "name": "n8n",
"version": "2.25.1", "version": "2.25.3",
"description": "n8n Workflow Automation Tool", "description": "n8n Workflow Automation Tool",
"main": "dist/index", "main": "dist/index",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -1,6 +1,7 @@
import { Logger } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils'; import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
import { ExecutionsConfig } from '@n8n/config'; import { ExecutionsConfig } from '@n8n/config';
import type { GlobalConfig } from '@n8n/config';
import type { ExecutionRepository } from '@n8n/db'; import type { ExecutionRepository } from '@n8n/db';
import type { Response } from 'express'; import type { Response } from 'express';
import { captor, mock } from 'jest-mock-extended'; import { captor, mock } from 'jest-mock-extended';
@ -22,7 +23,10 @@ import { v4 as uuid } from 'uuid';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
import type { EventService } from '@/events/event.service';
import type { ExecutionPersistence } from '@/executions/execution-persistence'; import type { ExecutionPersistence } from '@/executions/execution-persistence';
import type { License } from '@/license';
import type { Telemetry } from '@/telemetry';
jest.mock('n8n-workflow', () => ({ jest.mock('n8n-workflow', () => ({
...jest.requireActual('n8n-workflow'), ...jest.requireActual('n8n-workflow'),
@ -183,6 +187,91 @@ describe('ActiveExecutions', () => {
}); });
}); });
// TRUST-144: evaluation executions stayed in status 'new' (startedAt
// null) and never got picked up. The test-runner fan-out already throttles
// the shared evaluation concurrency queue before launching each case, so a
// second reservation here consumed a second slot from the same queue for
// the same case. Once the fan-out filled the queue up to its cap, this
// nested reservation blocked forever — before `setRunning` ran — leaving
// the execution stuck at status 'new'.
describe('evaluation executions do not double-reserve concurrency (TRUST-144)', () => {
// A real service whose evaluation queue has a single slot, already
// taken by the test-runner fan-out for this case.
const buildFullEvalConcurrencyControl = () => {
const service = new ConcurrencyControlService(
mockLogger(),
executionRepository,
mock<Telemetry>(),
mock<EventService>(),
mock<GlobalConfig>({
executions: {
mode: 'regular',
concurrency: { productionLimit: -1, evaluationLimit: 1 },
},
deployment: { type: 'default' },
}),
mock<License>(),
);
return service;
};
const evalExecutionData: IWorkflowExecutionDataProcess = {
...executionData,
executionMode: 'evaluation',
};
test('reaches setRunning even when the evaluation queue is full', async () => {
const realConcurrencyControl = buildFullEvalConcurrencyControl();
// The fan-out has taken the only evaluation slot for this case.
await realConcurrencyControl.throttle({
mode: 'evaluation',
executionId: 'run-1-case-0',
});
const evalActiveExecutions = new ActiveExecutions(
mock(),
executionRepository,
executionPersistence,
realConcurrencyControl,
mock(),
executionsConfig,
);
let resolvedId: string | undefined;
const addPromise = evalActiveExecutions.add(evalExecutionData).then((id) => {
resolvedId = id;
return id;
});
// If `add` re-reserved a slot it would block here (queue is full).
await new Promise((resolve) => setImmediate(resolve));
expect(resolvedId).toBe(FAKE_EXECUTION_ID);
expect(executionRepository.setRunning).toHaveBeenCalledWith(FAKE_EXECUTION_ID);
await addPromise;
});
test('does not throttle the evaluation queue', async () => {
const realConcurrencyControl = buildFullEvalConcurrencyControl();
const throttleSpy = jest.spyOn(realConcurrencyControl, 'throttle');
const evalActiveExecutions = new ActiveExecutions(
mock(),
executionRepository,
executionPersistence,
realConcurrencyControl,
mock(),
executionsConfig,
);
await evalActiveExecutions.add(evalExecutionData);
expect(throttleSpy).not.toHaveBeenCalled();
});
});
describe('attachWorkflowExecution', () => { describe('attachWorkflowExecution', () => {
test('Should fail attaching execution to invalid executionId', async () => { test('Should fail attaching execution to invalid executionId', async () => {
expect(() => { expect(() => {

View File

@ -67,6 +67,18 @@ export class ActiveExecutions {
const mode = executionData.executionMode; const mode = executionData.executionMode;
const capacityReservation = new ConcurrencyCapacityReservation(this.concurrencyControl); const capacityReservation = new ConcurrencyCapacityReservation(this.concurrencyControl);
// Evaluation executions are already gated instance-wide by the
// test-runner fan-out, which throttles the shared evaluation
// concurrency queue before launching each case (see
// `test-runner.service.ee.ts`). Reserving capacity again here would
// consume a second slot from the same queue for the same case; once
// the fan-out fills the queue up to its cap, this nested reservation
// blocks forever — before `setRunning` runs — leaving the execution
// stuck at status 'new' with `startedAt` null (TRUST-144). Skip the
// reservation for evaluation mode; `release()` below is a no-op when
// nothing was reserved.
const shouldReserveCapacity = mode !== 'evaluation';
try { try {
if (maybeExecutionId === undefined) { if (maybeExecutionId === undefined) {
const fullExecutionData: CreateExecutionPayload = { const fullExecutionData: CreateExecutionPayload = {
@ -89,7 +101,9 @@ export class ActiveExecutions {
maybeExecutionId = await this.executionPersistence.create(fullExecutionData); maybeExecutionId = await this.executionPersistence.create(fullExecutionData);
assert(maybeExecutionId); assert(maybeExecutionId);
await capacityReservation.reserve({ mode, executionId: maybeExecutionId }); if (shouldReserveCapacity) {
await capacityReservation.reserve({ mode, executionId: maybeExecutionId });
}
if (this.executionsConfig.mode === 'regular') { if (this.executionsConfig.mode === 'regular') {
await this.executionRepository.setRunning(maybeExecutionId); await this.executionRepository.setRunning(maybeExecutionId);
@ -98,7 +112,9 @@ export class ActiveExecutions {
} else { } else {
// Is an existing execution we want to finish so update in DB // Is an existing execution we want to finish so update in DB
await capacityReservation.reserve({ mode, executionId: maybeExecutionId }); if (shouldReserveCapacity) {
await capacityReservation.reserve({ mode, executionId: maybeExecutionId });
}
const execution: Pick<IExecutionDb, 'id' | 'data' | 'waitTill' | 'status'> = { const execution: Pick<IExecutionDb, 'id' | 'data' | 'waitTill' | 'status'> = {
id: maybeExecutionId, id: maybeExecutionId,

View File

@ -131,6 +131,12 @@ export class ImportWorkflowsCommand extends BaseCommand<z.infer<typeof flagsSche
const project = await this.getProject(flags.userId, flags.projectId); const project = await this.getProject(flags.userId, flags.projectId);
const ownerUser = await Container.get(UserRepository).findOneByOrFail({
role: { slug: GLOBAL_OWNER_ROLE.slug },
});
// This userId will be used as the actor for publish/unpublish workflow actions
const userId = flags.userId ?? ownerUser.id;
const workflows = await this.readWorkflows(flags.input, flags.separate); const workflows = await this.readWorkflows(flags.input, flags.separate);
const result = await this.checkRelations(workflows, flags.projectId, flags.userId); const result = await this.checkRelations(workflows, flags.projectId, flags.userId);
@ -141,7 +147,7 @@ export class ImportWorkflowsCommand extends BaseCommand<z.infer<typeof flagsSche
this.logger.info(`Importing ${workflows.length} workflows...`); this.logger.info(`Importing ${workflows.length} workflows...`);
await Container.get(ImportService).importWorkflows(workflows, project.id, { await Container.get(ImportService).importWorkflows(workflows, project.id, userId, {
activeState: flags.activeState, activeState: flags.activeState,
}); });

View File

@ -18,6 +18,7 @@ type ChatBotLike = ConstructorParameters<typeof AgentChatBridge>[0];
interface FakeThread { interface FakeThread {
id: string; id: string;
channelId?: string; channelId?: string;
adapter?: { botUserId?: string };
subscribe: jest.Mock; subscribe: jest.Mock;
post: jest.Mock; post: jest.Mock;
startTyping: jest.Mock; startTyping: jest.Mock;
@ -44,10 +45,11 @@ function makeBot() {
return { bot, handlers }; return { bot, handlers };
} }
function makeThread(): FakeThread { function makeThread(id = 'thread-1', adapter?: FakeThread['adapter']): FakeThread {
return { return {
id: 'thread-1', id,
channelId: 'channel-1', channelId: 'channel-1',
adapter,
subscribe: jest.fn().mockResolvedValue(undefined), subscribe: jest.fn().mockResolvedValue(undefined),
post: jest.fn().mockResolvedValue(undefined), post: jest.fn().mockResolvedValue(undefined),
startTyping: jest.fn().mockResolvedValue(undefined), startTyping: jest.fn().mockResolvedValue(undefined),
@ -97,6 +99,24 @@ class StreamingTestIntegration extends AgentChatIntegration {
} }
} }
class FormattedBufferedTestIntegration extends AgentChatIntegration {
readonly type = 'test-formatted-buffered';
readonly credentialTypes: string[] = [];
readonly supportedComponents: string[] = [];
readonly description = '';
readonly displayLabel = 'Test Formatted Buffered';
readonly displayIcon = 'circle';
readonly disableStreaming = true;
readonly formatThreadId = {
fromSdk: (thread: { id: string; adapter?: { botUserId?: string } }) =>
`chat:${thread.adapter?.botUserId ?? 'bot'}-${thread.id}`,
toSdk: (threadId: string) => threadId.split('-').slice(1).join('-'),
};
async createAdapter(_ctx: AgentChatIntegrationContext): Promise<unknown> {
return {};
}
}
// TODO: use real Telegram integration for testing // TODO: use real Telegram integration for testing
describe('AgentChatBridge — consumeStream', () => { describe('AgentChatBridge — consumeStream', () => {
@ -117,6 +137,7 @@ describe('AgentChatBridge — consumeStream', () => {
registry = new ChatIntegrationRegistry(); registry = new ChatIntegrationRegistry();
registry.register(new BufferingTestIntegration()); registry.register(new BufferingTestIntegration());
registry.register(new StreamingTestIntegration()); registry.register(new StreamingTestIntegration());
registry.register(new FormattedBufferedTestIntegration());
Container.set(ChatIntegrationRegistry, registry); Container.set(ChatIntegrationRegistry, registry);
}); });
@ -219,10 +240,11 @@ describe('AgentChatBridge — consumeStream', () => {
}); });
}); });
describe('when integration keeps streaming enabled', () => { describe('when deriving memory scope', () => {
it('uses the formatted chat thread as the episodic memory partition', async () => { it('uses the platform user as the episodic memory partition across threads', async () => {
const { bot, handlers } = makeBot(); const { bot, handlers } = makeBot();
const thread = makeThread(); const thread1 = makeThread('thread-1');
const thread2 = makeThread('thread-2');
const agentExecutor = makeAgentExecutor([{ type: 'finish', finishReason: 'stop' }]); const agentExecutor = makeAgentExecutor([{ type: 'finish', finishReason: 'stop' }]);
new AgentChatBridge( new AgentChatBridge(
@ -235,18 +257,79 @@ describe('AgentChatBridge — consumeStream', () => {
streamingIntegration, streamingIntegration,
); );
await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } }); await handlers.mention!(thread1, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
await handlers.mention!(thread2, {
text: 'what did we discuss?',
author: { userId: 'u1', userName: 'user1' },
});
expect(agentExecutor.executeForChatPublished).toHaveBeenCalledWith( expect(agentExecutor.executeForChatPublished).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ expect.objectContaining({
memory: expect.objectContaining({ memory: expect.objectContaining({
threadId: expect.objectContaining({ id: 'agent-1:thread-1' }), threadId: expect.objectContaining({ id: 'agent-1:thread-1' }),
resourceId: 'integration:test-streaming:thread-1', resourceId: 'integration:test-streaming:u1',
}),
}),
);
expect(agentExecutor.executeForChatPublished).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
memory: expect.objectContaining({
threadId: expect.objectContaining({ id: 'agent-1:thread-2' }),
resourceId: 'integration:test-streaming:u1',
}), }),
}), }),
); );
}); });
it('keeps a formatted thread ID separate from the platform user memory partition', async () => {
const { bot, handlers } = makeBot();
const thread1 = makeThread('1001', { botUserId: 'bot-1' });
const thread2 = makeThread('1002', { botUserId: 'bot-1' });
const agentExecutor = makeAgentExecutor([{ type: 'finish', finishReason: 'stop' }]);
new AgentChatBridge(
bot as unknown as ChatBotLike,
'agent-1',
agentExecutor as never,
componentMapper,
logger,
'project-1',
{
type: 'test-formatted-buffered',
credentialId: 'cred-1',
} as unknown as AgentIntegrationConfig,
);
await handlers.mention!(thread1, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
await handlers.mention!(thread2, {
text: 'what did we discuss?',
author: { userId: 'u1', userName: 'user1' },
});
expect(agentExecutor.executeForChatPublished).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
memory: expect.objectContaining({
threadId: expect.objectContaining({ id: 'agent-1:chat:bot-1-1001' }),
resourceId: 'integration:test-formatted-buffered:u1',
}),
}),
);
expect(agentExecutor.executeForChatPublished).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
memory: expect.objectContaining({
threadId: expect.objectContaining({ id: 'agent-1:chat:bot-1-1002' }),
resourceId: 'integration:test-formatted-buffered:u1',
}),
}),
);
});
});
describe('when integration keeps streaming enabled', () => {
it('posts an AsyncIterable whose drained content equals the concatenated deltas', async () => { it('posts an AsyncIterable whose drained content equals the concatenated deltas', async () => {
const { bot, handlers } = makeBot(); const { bot, handlers } = makeBot();
const thread = makeThread(); const thread = makeThread();

View File

@ -314,7 +314,8 @@ export class AgentChatBridge {
subject, subject,
}); });
// threadId.id is agent-prefixed for observation storage; resourceId keeps // threadId.id is agent-prefixed for observation storage; resourceId keeps
// the platform identity so episodic recall remains agent + resource scoped. // the platform user identity so episodic recall works across threads for
// the same user while staying isolated between users.
// Always run the published snapshot — integrations are production traffic. // Always run the published snapshot — integrations are production traffic.
const stream = this.agentService.executeForChatPublished({ const stream = this.agentService.executeForChatPublished({
agentId: this.agentId, agentId: this.agentId,
@ -322,7 +323,7 @@ export class AgentChatBridge {
message: text, message: text,
memory: { memory: {
threadId, threadId,
resourceId: integrationMemoryResourceId(this.integration.type, platformThreadId), resourceId: integrationMemoryResourceId(this.integration.type, message.author.userId),
}, },
integrationType: this.integration.type, integrationType: this.integration.type,
}); });

View File

@ -2,8 +2,11 @@ export function draftChatMemoryResourceId(userId: string): string {
return `draft-chat:${userId}`; return `draft-chat:${userId}`;
} }
export function integrationMemoryResourceId(integrationType: string, threadId: string): string { export function integrationMemoryResourceId(
return `integration:${integrationType}:${threadId}`; integrationType: string,
platformUserId: string,
): string {
return `integration:${integrationType}:${platformUserId}`;
} }
export function taskRunMemoryResourceId(taskId: string): string { export function taskRunMemoryResourceId(taskId: string): string {

View File

@ -75,7 +75,7 @@ describe('OtelLifecycleHandler', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
config = makeOtelConfig({ publishedOnly: false }); config = makeOtelConfig({ productionExecutionsOnly: false });
handler = new OtelLifecycleHandler( handler = new OtelLifecycleHandler(
tracer, tracer,
traceContextService, traceContextService,
@ -321,7 +321,7 @@ describe('OtelLifecycleHandler', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
config = makeOtelConfig({ publishedOnly: false }); config = makeOtelConfig({ productionExecutionsOnly: false });
handler = new OtelLifecycleHandler( handler = new OtelLifecycleHandler(
tracer, tracer,
traceContextService, traceContextService,
@ -437,7 +437,7 @@ describe('OtelLifecycleHandler', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
config = makeOtelConfig({ publishedOnly: false }); config = makeOtelConfig({ productionExecutionsOnly: false });
handler = new OtelLifecycleHandler( handler = new OtelLifecycleHandler(
tracer, tracer,
traceContextService, traceContextService,
@ -542,7 +542,7 @@ describe('OtelLifecycleHandler', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
config = makeOtelConfig({ publishedOnly: false, includeNodeSpans: true }); config = makeOtelConfig({ productionExecutionsOnly: false, includeNodeSpans: true });
handler = new OtelLifecycleHandler( handler = new OtelLifecycleHandler(
tracer, tracer,
traceContextService, traceContextService,
@ -636,7 +636,7 @@ describe('OtelLifecycleHandler', () => {
}); });
}); });
describe('publishedOnly filter', () => { describe('productionExecutionsOnly filter', () => {
const tracer = mock<ExecutionLevelTracer>(); const tracer = mock<ExecutionLevelTracer>();
const traceContextService = mock<TraceContextService>(); const traceContextService = mock<TraceContextService>();
let config = makeOtelConfig(); let config = makeOtelConfig();
@ -705,7 +705,7 @@ describe('publishedOnly filter', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
config = makeOtelConfig({ publishedOnly: true, includeNodeSpans: true }); config = makeOtelConfig({ productionExecutionsOnly: true, includeNodeSpans: true });
ownershipService.getWorkflowProjectCached.mockResolvedValue({ id: 'proj-1' } as never); ownershipService.getWorkflowProjectCached.mockResolvedValue({ id: 'proj-1' } as never);
traceContextService.get.mockResolvedValue(undefined); traceContextService.get.mockResolvedValue(undefined);
handler = new OtelLifecycleHandler( handler = new OtelLifecycleHandler(
@ -717,7 +717,7 @@ describe('publishedOnly filter', () => {
); );
}); });
it('should skip all tracing for an inactive workflow when publishedOnly is true', async () => { it('should skip all tracing for an inactive workflow when productionExecutionsOnly is true', async () => {
await handler.onWorkflowStart(makeWorkflowStartCtx(inactiveWorkflow)); await handler.onWorkflowStart(makeWorkflowStartCtx(inactiveWorkflow));
handler.onWorkflowEnd(makeWorkflowEndCtx(inactiveWorkflow)); handler.onWorkflowEnd(makeWorkflowEndCtx(inactiveWorkflow));
handler.onNodeStart(makeNodeStartCtx(inactiveWorkflow)); handler.onNodeStart(makeNodeStartCtx(inactiveWorkflow));
@ -730,7 +730,7 @@ describe('publishedOnly filter', () => {
expect(tracer.endNode).not.toHaveBeenCalled(); expect(tracer.endNode).not.toHaveBeenCalled();
}); });
it('should trace an active workflow when publishedOnly is true', async () => { it('should trace an active workflow when productionExecutionsOnly is true', async () => {
tracer.startWorkflow.mockReturnValue({ traceparent: '00-abc-def-01' }); tracer.startWorkflow.mockReturnValue({ traceparent: '00-abc-def-01' });
await handler.onWorkflowStart(makeWorkflowStartCtx(activeWorkflow)); await handler.onWorkflowStart(makeWorkflowStartCtx(activeWorkflow));
@ -739,8 +739,8 @@ describe('publishedOnly filter', () => {
expect(traceContextService.persist).toHaveBeenCalled(); expect(traceContextService.persist).toHaveBeenCalled();
}); });
it('should trace an inactive workflow when publishedOnly is false', async () => { it('should trace an inactive workflow when productionExecutionsOnly is false', async () => {
config.publishedOnly = false; config.productionExecutionsOnly = false;
tracer.startWorkflow.mockReturnValue({ traceparent: '00-abc-def-01' }); tracer.startWorkflow.mockReturnValue({ traceparent: '00-abc-def-01' });
await handler.onWorkflowStart(makeWorkflowStartCtx(inactiveWorkflow)); await handler.onWorkflowStart(makeWorkflowStartCtx(inactiveWorkflow));

View File

@ -30,7 +30,7 @@ beforeAll(async () => {
savedEnv = saveAndSetEnv({ savedEnv = saveAndSetEnv({
N8N_OTEL_ENABLED: 'true', N8N_OTEL_ENABLED: 'true',
N8N_OTEL_TRACES_INCLUDE_NODE_SPANS: 'true', N8N_OTEL_TRACES_INCLUDE_NODE_SPANS: 'true',
N8N_OTEL_TRACES_PUBLISHED_ONLY: 'false', N8N_OTEL_TRACES_PRODUCTION_ONLY: 'false',
}); });
const env = await initOtelTestEnvironment(); const env = await initOtelTestEnvironment();
otel = env.otel; otel = env.otel;

View File

@ -51,7 +51,7 @@ export class OtelLifecycleHandler {
@OnLifecycleEvent('workflowExecuteBefore') @OnLifecycleEvent('workflowExecuteBefore')
async onWorkflowStart(ctx: WorkflowExecuteBeforeContext): Promise<void> { async onWorkflowStart(ctx: WorkflowExecuteBeforeContext): Promise<void> {
if (this.config.publishedOnly && !this.isPublishedWorkflow(ctx.workflow)) return; if (this.config.productionExecutionsOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
const parentExecutionId = ctx.executionData?.parentExecution?.executionId; const parentExecutionId = ctx.executionData?.parentExecution?.executionId;
const tracingContext = parentExecutionId const tracingContext = parentExecutionId
@ -96,7 +96,7 @@ export class OtelLifecycleHandler {
@OnLifecycleEvent('workflowExecuteResume') @OnLifecycleEvent('workflowExecuteResume')
async onWorkflowResume(ctx: WorkflowExecuteResumeContext): Promise<void> { async onWorkflowResume(ctx: WorkflowExecuteResumeContext): Promise<void> {
if (this.config.publishedOnly && !this.isPublishedWorkflow(ctx.workflow)) return; if (this.config.productionExecutionsOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
const previousWorkflowExecution = await this.traceContextService.get(ctx.executionId); const previousWorkflowExecution = await this.traceContextService.get(ctx.executionId);
@ -132,7 +132,7 @@ export class OtelLifecycleHandler {
@OnLifecycleEvent('workflowExecuteAfter') @OnLifecycleEvent('workflowExecuteAfter')
onWorkflowEnd(ctx: WorkflowExecuteAfterContext): void { onWorkflowEnd(ctx: WorkflowExecuteAfterContext): void {
if (this.config.publishedOnly && !this.isPublishedWorkflow(ctx.workflow)) return; if (this.config.productionExecutionsOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
this.tracer.endWorkflow({ this.tracer.endWorkflow({
executionId: ctx.executionId, executionId: ctx.executionId,
@ -146,7 +146,7 @@ export class OtelLifecycleHandler {
@OnLifecycleEvent('nodeExecuteBefore') @OnLifecycleEvent('nodeExecuteBefore')
onNodeStart(ctx: NodeExecuteBeforeContext): void { onNodeStart(ctx: NodeExecuteBeforeContext): void {
if (this.config.publishedOnly && !this.isPublishedWorkflow(ctx.workflow)) return; if (this.config.productionExecutionsOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
if (!this.config.includeNodeSpans) return; if (!this.config.includeNodeSpans) return;
const node = ctx.workflow.nodes.find((n) => n.name === ctx.nodeName); const node = ctx.workflow.nodes.find((n) => n.name === ctx.nodeName);
@ -160,7 +160,7 @@ export class OtelLifecycleHandler {
@OnLifecycleEvent('nodeExecuteAfter') @OnLifecycleEvent('nodeExecuteAfter')
onNodeEnd(ctx: NodeExecuteAfterContext): void { onNodeEnd(ctx: NodeExecuteAfterContext): void {
if (this.config.publishedOnly && !this.isPublishedWorkflow(ctx.workflow)) return; if (this.config.productionExecutionsOnly && !this.isPublishedWorkflow(ctx.workflow)) return;
if (!this.config.includeNodeSpans) return; if (!this.config.includeNodeSpans) return;
const node = ctx.workflow.nodes.find((n) => n.name === ctx.nodeName); const node = ctx.workflow.nodes.find((n) => n.name === ctx.nodeName);

View File

@ -29,7 +29,7 @@ export class OtelConfig {
@Env('N8N_OTEL_TRACES_INJECT_OUTBOUND') @Env('N8N_OTEL_TRACES_INJECT_OUTBOUND')
injectOutbound: boolean = true; injectOutbound: boolean = true;
/** When true, only traces executions of published (active) workflows. */ /** When true, only traces production executions of published (active) workflows, not manual/test runs. */
@Env('N8N_OTEL_TRACES_PUBLISHED_ONLY') @Env('N8N_OTEL_TRACES_PRODUCTION_ONLY')
publishedOnly: boolean = true; productionExecutionsOnly: boolean = true;
} }

View File

@ -1,18 +1,13 @@
import { safeJoinPath, type Logger } from '@n8n/backend-common'; import { safeJoinPath, type Logger } from '@n8n/backend-common';
import type { import type { CredentialsRepository, TagRepository, UserRepository } from '@n8n/db';
CredentialsRepository,
TagRepository,
WorkflowPublishHistoryRepository,
WorkflowRepository,
} from '@n8n/db';
import { type DataSource, type EntityManager } from '@n8n/typeorm'; import { type DataSource, type EntityManager } from '@n8n/typeorm';
import { readdir, readFile } from 'fs/promises'; import { readdir, readFile } from 'fs/promises';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { Cipher } from 'n8n-core'; import type { Cipher } from 'n8n-core';
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
import type { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service'; import type { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service';
import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service'; import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
import type { WorkflowService } from '@/workflows/workflow.service';
import { ImportService } from '../import.service'; import { ImportService } from '../import.service';
@ -35,10 +30,6 @@ jest.mock('@n8n/db', () => ({
WithTimestampsAndStringId: class {}, WithTimestampsAndStringId: class {},
})); }));
jest.mock('@/active-workflow-manager', () => ({
ActiveWorkflowManager: mock<ActiveWorkflowManager>(),
}));
describe('ImportService', () => { describe('ImportService', () => {
let importService: ImportService; let importService: ImportService;
let mockLogger: Logger; let mockLogger: Logger;
@ -47,11 +38,10 @@ describe('ImportService', () => {
let mockTagRepository: TagRepository; let mockTagRepository: TagRepository;
let mockEntityManager: EntityManager; let mockEntityManager: EntityManager;
let mockCipher: Cipher; let mockCipher: Cipher;
let mockActiveWorkflowManager: ActiveWorkflowManager;
let mockWorkflowIndexService: WorkflowIndexService; let mockWorkflowIndexService: WorkflowIndexService;
let mockDataTableDDLService: DataTableDDLService; let mockDataTableDDLService: DataTableDDLService;
let mockWorkflowRepository: WorkflowRepository; let mockUserRepository: UserRepository;
let mockWorkflowPublishHistoryRepository: WorkflowPublishHistoryRepository; let mockWorkflowService: WorkflowService;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@ -62,11 +52,10 @@ describe('ImportService', () => {
mockTagRepository = mock<TagRepository>(); mockTagRepository = mock<TagRepository>();
mockEntityManager = mock<EntityManager>(); mockEntityManager = mock<EntityManager>();
mockCipher = mock<Cipher>(); mockCipher = mock<Cipher>();
mockActiveWorkflowManager = mock<ActiveWorkflowManager>();
mockWorkflowIndexService = mock<WorkflowIndexService>(); mockWorkflowIndexService = mock<WorkflowIndexService>();
mockDataTableDDLService = mock<DataTableDDLService>(); mockDataTableDDLService = mock<DataTableDDLService>();
mockWorkflowRepository = mock<WorkflowRepository>(); mockUserRepository = mock<UserRepository>();
mockWorkflowPublishHistoryRepository = mock<WorkflowPublishHistoryRepository>(); mockWorkflowService = mock<WorkflowService>();
// Set up cipher mock // Set up cipher mock
mockCipher.decryptV2 = jest.fn(async (data: string) => mockCipher.decryptV2 = jest.fn(async (data: string) =>
@ -116,11 +105,10 @@ describe('ImportService', () => {
mockTagRepository, mockTagRepository,
mockDataSource, mockDataSource,
mockCipher, mockCipher,
mockActiveWorkflowManager,
mockWorkflowIndexService, mockWorkflowIndexService,
mockDataTableDDLService, mockDataTableDDLService,
mockWorkflowRepository, mockUserRepository,
mockWorkflowPublishHistoryRepository, mockWorkflowService,
); );
}); });
@ -1252,6 +1240,234 @@ describe('ImportService', () => {
}); });
}); });
describe('extractSubworkflowId', () => {
it('should extract workflow ID from legacy string format', () => {
const node = {
parameters: { workflowId: 'abc123' },
};
// @ts-expect-error accessing private method for testing
expect(importService.extractSubworkflowId(node)).toBe('abc123');
});
it('should extract workflow ID from resource-locator object format', () => {
const node = {
parameters: {
workflowId: { __rl: true, value: 'LCEM9GnTcIVSy1D8', mode: 'list' },
},
};
// @ts-expect-error accessing private method for testing
expect(importService.extractSubworkflowId(node)).toBe('LCEM9GnTcIVSy1D8');
});
it('should return undefined when workflowId is missing', () => {
const node = {
parameters: {},
};
// @ts-expect-error accessing private method for testing
expect(importService.extractSubworkflowId(node)).toBeUndefined();
});
});
describe('sortWorkflowsForActivation', () => {
function makeNode(id: string, type: string, parameters: Record<string, unknown> = {}) {
return { id, type, parameters, disabled: false } as any;
}
function makeExecuteWorkflowNode(id: string, calleeId: string) {
return makeNode(id, 'n8n-nodes-base.executeWorkflow', {
workflowId: calleeId,
});
}
function makeWorkflow(id: string, nodes: any[] = []) {
return { id, nodes } as any;
}
function makeToActivate(ids: string[]) {
return ids.map((id) => ({ workflowId: id, versionId: `v-${id}` }));
}
it('should return single workflow unchanged', () => {
const workflows = [makeWorkflow('A')];
const toActivate = makeToActivate(['A']);
// @ts-expect-error accessing private method for testing
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
expect(result.map((w) => w.workflowId)).toEqual(['A']);
});
it('should activate callee (B) before caller (A) — simple A→B case', () => {
const workflows = [
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'B')]),
makeWorkflow('B'),
];
const toActivate = makeToActivate(['A', 'B']);
// @ts-expect-error accessing private method for testing
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
expect(result.map((w) => w.workflowId)).toEqual(['B', 'A']);
});
it('should activate C → B → A for a three-level chain', () => {
const workflows = [
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'B')]),
makeWorkflow('B', [makeExecuteWorkflowNode('n2', 'C')]),
makeWorkflow('C'),
];
const toActivate = makeToActivate(['A', 'B', 'C']);
// @ts-expect-error accessing private method for testing
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
expect(result.map((w) => w.workflowId)).toEqual(['C', 'B', 'A']);
});
it('should activate both B and C before A when A calls both', () => {
const workflows = [
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'B'), makeExecuteWorkflowNode('n2', 'C')]),
makeWorkflow('B'),
makeWorkflow('C'),
];
const toActivate = makeToActivate(['A', 'B', 'C']);
// @ts-expect-error accessing private method for testing
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
const ids = result.map((w) => w.workflowId);
expect(ids.indexOf('B')).toBeLessThan(ids.indexOf('A'));
expect(ids.indexOf('C')).toBeLessThan(ids.indexOf('A'));
expect(ids).toHaveLength(3);
});
it('should ignore referenced workflows not present in the activation batch', () => {
const workflows = [
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'EXTERNAL')]),
makeWorkflow('B'),
];
const toActivate = makeToActivate(['A', 'B']);
// @ts-expect-error accessing private method for testing
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
expect(result).toHaveLength(2);
expect(result.map((w) => w.workflowId)).toContain('A');
expect(result.map((w) => w.workflowId)).toContain('B');
});
it('should skip disabled executeWorkflow nodes when building the dependency graph', () => {
const disabledNode = { ...makeExecuteWorkflowNode('n1', 'B'), disabled: true };
const workflows = [makeWorkflow('A', [disabledNode]), makeWorkflow('B')];
const toActivate = makeToActivate(['A', 'B']);
// @ts-expect-error accessing private method for testing
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
// A has no active dependencies on B, so order can be anything — just verify both present
expect(result).toHaveLength(2);
});
it('should handle resource-locator workflowId format in nodes', () => {
const node = makeNode('n1', 'n8n-nodes-base.executeWorkflow', {
workflowId: { __rl: true, value: 'B', mode: 'list' },
});
const workflows = [makeWorkflow('A', [node]), makeWorkflow('B')];
const toActivate = makeToActivate(['A', 'B']);
// @ts-expect-error accessing private method for testing
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
expect(result.map((w) => w.workflowId)).toEqual(['B', 'A']);
});
it('should return original order (fast path) when no workflow references another batch workflow', () => {
// Both workflows have executeWorkflow nodes, but they point to external IDs not in batch
const workflows = [
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'EXTERNAL_1')]),
makeWorkflow('B', [makeExecuteWorkflowNode('n2', 'EXTERNAL_2')]),
];
const toActivate = makeToActivate(['A', 'B']);
// @ts-expect-error accessing private method for testing
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
// Same reference → fast path returned the original array unchanged
expect(result).toBe(toActivate);
});
it('should return original order (fast path) when no workflows have executeWorkflow nodes', () => {
const workflows = [makeWorkflow('A'), makeWorkflow('B'), makeWorkflow('C')];
const toActivate = makeToActivate(['A', 'B', 'C']);
// @ts-expect-error accessing private method for testing
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
expect(result).toBe(toActivate);
});
it('should append mutually-cyclic workflows (A↔B) and log a warning', () => {
const workflows = [
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'B')]),
makeWorkflow('B', [makeExecuteWorkflowNode('n2', 'A')]),
];
const toActivate = makeToActivate(['A', 'B']);
// @ts-expect-error accessing private method for testing
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
expect(result).toHaveLength(2);
expect(result.map((w) => w.workflowId)).toContain('A');
expect(result.map((w) => w.workflowId)).toContain('B');
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('circular'));
});
it('should append all workflows in a three-way cycle (A→B→C→A) and log a warning', () => {
const workflows = [
makeWorkflow('A', [makeExecuteWorkflowNode('n1', 'B')]),
makeWorkflow('B', [makeExecuteWorkflowNode('n2', 'C')]),
makeWorkflow('C', [makeExecuteWorkflowNode('n3', 'A')]),
];
const toActivate = makeToActivate(['A', 'B', 'C']);
// @ts-expect-error accessing private method for testing
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
expect(result).toHaveLength(3);
expect(result.map((w) => w.workflowId)).toContain('A');
expect(result.map((w) => w.workflowId)).toContain('B');
expect(result.map((w) => w.workflowId)).toContain('C');
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('circular'));
});
it('should sort non-cyclic workflows first and append cyclic workflows at the end', () => {
// D→E: normal dependency, no cycle
// A↔B: mutual cycle
const workflows = [
makeWorkflow('D', [makeExecuteWorkflowNode('n1', 'E')]),
makeWorkflow('E'),
makeWorkflow('A', [makeExecuteWorkflowNode('n2', 'B')]),
makeWorkflow('B', [makeExecuteWorkflowNode('n3', 'A')]),
];
const toActivate = makeToActivate(['D', 'E', 'A', 'B']);
// @ts-expect-error accessing private method for testing
const result = importService.sortWorkflowsForActivation(workflows, toActivate);
expect(result).toHaveLength(4);
const ids = result.map((w) => w.workflowId);
// E must come before D (normal dependency order)
expect(ids.indexOf('E')).toBeLessThan(ids.indexOf('D'));
// A and B are cyclic — they appear after the sorted pair
expect(ids.indexOf('A')).toBeGreaterThan(ids.indexOf('D'));
expect(ids.indexOf('B')).toBeGreaterThan(ids.indexOf('D'));
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('circular'));
});
});
describe('advanceIdentitySequences', () => { describe('advanceIdentitySequences', () => {
it('should run setval for each identity column on Postgres', async () => { it('should run setval for each identity column on Postgres', async () => {
// @ts-expect-error overriding for the test // @ts-expect-error overriding for the test

View File

@ -1,5 +1,5 @@
import { Logger, safeJoinPath } from '@n8n/backend-common'; import { Logger, safeJoinPath } from '@n8n/backend-common';
import type { TagEntity, ICredentialsDb } from '@n8n/db'; import type { TagEntity, ICredentialsDb, User } from '@n8n/db';
import { import {
Project, Project,
WorkflowEntity, WorkflowEntity,
@ -7,15 +7,15 @@ import {
WorkflowTagMapping, WorkflowTagMapping,
CredentialsRepository, CredentialsRepository,
TagRepository, TagRepository,
UserRepository,
WorkflowHistory, WorkflowHistory,
WorkflowPublishHistory,
WorkflowPublishHistoryRepository,
WorkflowRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Service } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { DataSource, EntityManager, In, type EntityMetadata } from '@n8n/typeorm'; import { DataSource, EntityManager, In, type EntityMetadata } from '@n8n/typeorm';
import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
import { Service } from '@n8n/di'; import { readdir, readFile } from 'fs/promises';
import { Cipher } from 'n8n-core';
import { import {
ensureError, ensureError,
type INode, type INode,
@ -23,23 +23,21 @@ import {
type IWorkflowBase, type IWorkflowBase,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { readdir, readFile } from 'fs/promises';
import { replaceInvalidCredentials, validateWorkflowStructure } from '@/workflow-helpers';
import { validateDbTypeForImportEntities } from '@/utils/validate-database-type';
import { Cipher } from 'n8n-core';
import { decompressFolder } from '@/utils/compression.util';
import { z } from 'zod'; import { z } from 'zod';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import type { IWorkflowWithVersionMetadata } from '@/interfaces'; import type { IWorkflowWithVersionMetadata } from '@/interfaces';
import { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
import { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service';
import type { DataTableColumn } from '@/modules/data-table/data-table-column.entity'; import type { DataTableColumn } from '@/modules/data-table/data-table-column.entity';
import { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service';
import { import {
normalizeUserRowValueForDatabase, normalizeUserRowValueForDatabase,
quoteIdentifier, quoteIdentifier,
toTableName, toTableName,
} from '@/modules/data-table/utils/sql-utils'; } from '@/modules/data-table/utils/sql-utils';
import { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
import { decompressFolder } from '@/utils/compression.util';
import { validateDbTypeForImportEntities } from '@/utils/validate-database-type';
import { replaceInvalidCredentials, validateWorkflowStructure } from '@/workflow-helpers';
import { WorkflowService } from '@/workflows/workflow.service';
const DATA_TABLE_ROWS_FILE_PREFIX = 'data_table_user_'; const DATA_TABLE_ROWS_FILE_PREFIX = 'data_table_user_';
@ -72,11 +70,10 @@ export class ImportService {
private readonly tagRepository: TagRepository, private readonly tagRepository: TagRepository,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly cipher: Cipher, private readonly cipher: Cipher,
private readonly activeWorkflowManager: ActiveWorkflowManager,
private readonly workflowIndexService: WorkflowIndexService, private readonly workflowIndexService: WorkflowIndexService,
private readonly dataTableDDLService: DataTableDDLService, private readonly dataTableDDLService: DataTableDDLService,
private readonly workflowRepository: WorkflowRepository, private readonly userRepository: UserRepository,
private readonly workflowPublishHistoryRepository: WorkflowPublishHistoryRepository, private readonly workflowService: WorkflowService,
) {} ) {}
async initRecords() { async initRecords() {
@ -87,10 +84,16 @@ export class ImportService {
async importWorkflows( async importWorkflows(
workflows: IWorkflowWithVersionMetadata[], workflows: IWorkflowWithVersionMetadata[],
projectId: string, projectId: string,
{ activeState = 'false' }: { activeState?: 'false' | 'fromJson' } = {}, userId: string,
{ activeState = 'false' }: { activeState?: 'false' | 'fromJson' },
) { ) {
await this.initRecords(); await this.initRecords();
const user = await this.userRepository.findOneOrFail({
where: { id: userId },
relations: ['role'],
});
const { manager: dbManager } = this.credentialsRepository; const { manager: dbManager } = this.credentialsRepository;
// Check existence and active status of all workflows // Check existence and active status of all workflows
@ -124,18 +127,16 @@ export class ImportService {
if (hasInvalidCreds) await this.replaceInvalidCreds(workflow, projectId); if (hasInvalidCreds) await this.replaceInvalidCreds(workflow, projectId);
validateWorkflowStructure(workflow); validateWorkflowStructure(workflow);
// Remove workflows from ActiveWorkflowManager BEFORE transaction to prevent orphaned trigger listeners // Deactivate BEFORE the transaction to prevent orphaned trigger listeners.
// Only remove if the workflow already exists in the database and is active // Only applies to workflows that are currently active in the database.
if (workflow.id && activeVersionIdByWorkflow.has(workflow.id)) { if (workflow.id && activeVersionIdByWorkflow.has(workflow.id)) {
await this.activeWorkflowManager.remove(workflow.id); await this.workflowService.deactivateWorkflow(user, workflow.id, { source: 'import' });
} }
} }
const insertedWorkflows: IWorkflowWithVersionMetadata[] = []; const insertedWorkflows: IWorkflowWithVersionMetadata[] = [];
const workflowsToActivate: Array<{ workflowId: string; versionId: string }> = []; const workflowsToActivate: Array<{ workflowId: string; versionId: string }> = [];
await dbManager.transaction(async (tx) => { await dbManager.transaction(async (tx) => {
const workflowsNeedingPublishHistory: Array<{ workflowId: string; versionId: string }> = [];
// Upsert all workflows // Upsert all workflows
for (const workflow of workflows) { for (const workflow of workflows) {
// Always generate a new versionId on import to ensure proper history ordering // Always generate a new versionId on import to ensure proper history ordering
@ -163,11 +164,6 @@ export class ImportService {
const workflowId = upsertResult.identifiers.at(0)?.id as string; const workflowId = upsertResult.identifiers.at(0)?.id as string;
insertedWorkflows.push({ ...workflow, id: workflowId }); // Collect inserted workflow with correct ID, for indexing later. insertedWorkflows.push({ ...workflow, id: workflowId }); // Collect inserted workflow with correct ID, for indexing later.
// Only add publish history if workflow was previously active
if (oldActiveVersionId) {
workflowsNeedingPublishHistory.push({ workflowId, versionId: oldActiveVersionId });
}
if (shouldActivate) { if (shouldActivate) {
workflowsToActivate.push({ workflowId, versionId: versionIdToActivate }); workflowsToActivate.push({ workflowId, versionId: versionIdToActivate });
} }
@ -210,20 +206,14 @@ export class ImportService {
description: versionMetadata?.description ?? null, description: versionMetadata?.description ?? null,
}); });
} }
// Add publish history records for workflows that were deactivated
for (const { workflowId, versionId } of workflowsNeedingPublishHistory) {
await tx.insert(WorkflowPublishHistory, {
workflowId,
versionId,
event: 'deactivated',
userId: null,
});
}
}); });
for (const { workflowId, versionId } of workflowsToActivate) { const orderedWorkflowsToActivate = this.sortWorkflowsForActivation(
await this.activateWorkflow(workflowId, versionId); insertedWorkflows,
workflowsToActivate,
);
for (const { workflowId, versionId } of orderedWorkflowsToActivate) {
await this.activateWorkflow(workflowId, versionId, user);
} }
// Directly update the index for the important workflows, since they don't generate // Directly update the index for the important workflows, since they don't generate
@ -233,29 +223,101 @@ export class ImportService {
} }
} }
private async activateWorkflow(workflowId: string, versionIdToActivate: string): Promise<void> { /**
let didActivate = false; * Sorts workflows to activate in dependency order so that subworkflows are activated
try { * before the workflows that call them. Uses Kahn's topological sort algorithm.
await this.workflowRepository.update( */
{ id: workflowId }, private sortWorkflowsForActivation(
{ activeVersionId: versionIdToActivate }, allImportedWorkflows: IWorkflowWithVersionMetadata[],
); toActivate: Array<{ workflowId: string; versionId: string }>,
await this.workflowRepository.updateActiveState(workflowId, true); ): Array<{ workflowId: string; versionId: string }> {
await this.activeWorkflowManager.add(workflowId, 'activate'); if (toActivate.length <= 1) return toActivate;
didActivate = true;
} catch (e) { const nodesByWorkflowId = new Map(allImportedWorkflows.map((w) => [w.id, w.nodes]));
const error = ensureError(e); const activateIds = new Set(toActivate.map((w) => w.workflowId));
this.logger.error(`Failed to activate workflow ${workflowId}`, { error });
} finally { // Fast path: skip the full graph build if no workflow in the batch references
if (didActivate) { // another batch workflow via an active executeWorkflow node.
await this.workflowPublishHistoryRepository.addRecord({ const hasCrossReference = toActivate.some(({ workflowId }) =>
workflowId, (nodesByWorkflowId.get(workflowId) ?? []).some(
versionId: versionIdToActivate, (node) =>
event: 'activated', !node.disabled &&
userId: null, node.type === 'n8n-nodes-base.executeWorkflow' &&
}); activateIds.has(this.extractSubworkflowId(node) ?? ''),
),
);
if (!hasCrossReference) return toActivate;
const toActivateByWorkflowId = new Map(toActivate.map((w) => [w.workflowId, w]));
// callee id → set of caller ids that depend on it being activated first
const dependents = new Map<string, Set<string>>(
toActivate.map(({ workflowId }) => [workflowId, new Set()]),
);
// caller id → how many of its subworkflow dependencies in this batch are not yet activated
const unresolvedDepsCount = new Map<string, number>(
toActivate.map(({ workflowId }) => [workflowId, 0]),
);
for (const { workflowId } of toActivate) {
for (const node of nodesByWorkflowId.get(workflowId) ?? []) {
if (node.disabled || node.type !== 'n8n-nodes-base.executeWorkflow') continue;
const calleeId = this.extractSubworkflowId(node);
if (!calleeId || !activateIds.has(calleeId) || calleeId === workflowId) continue;
dependents.get(calleeId)!.add(workflowId);
unresolvedDepsCount.set(workflowId, unresolvedDepsCount.get(workflowId)! + 1);
} }
} }
const queue = toActivate.filter((w) => unresolvedDepsCount.get(w.workflowId) === 0);
const result: Array<{ workflowId: string; versionId: string }> = [];
while (queue.length > 0) {
const item = queue.shift()!;
result.push(item);
for (const callerId of dependents.get(item.workflowId)!) {
const remaining = unresolvedDepsCount.get(callerId)! - 1;
unresolvedDepsCount.set(callerId, remaining);
if (remaining === 0) queue.push(toActivateByWorkflowId.get(callerId)!);
}
}
if (result.length < toActivate.length) {
// Any workflow still with unresolvedDepsCount > 0 was never enqueued by the
// sort — it's part of a cycle (its dependency also waits on it). Append these
// so they still get activated rather than being silently dropped.
const cycleWorkflows = toActivate.filter(
(w) => Number(unresolvedDepsCount.get(w.workflowId)) > 0,
);
this.logger.warn(
`Detected circular subworkflow references among workflows: [${cycleWorkflows.map((w) => w.workflowId).join(', ')}]. Activating them in original order.`,
);
result.push(...cycleWorkflows);
}
return result;
}
private extractSubworkflowId(node: INode): string | undefined {
const source = node.parameters?.['source'];
if (source === 'parameter' || source === 'localFile' || source === 'url') return undefined;
const wfId = node.parameters?.['workflowId'];
const rawId = typeof wfId === 'string' ? wfId : (wfId as { value?: unknown } | null)?.value;
return typeof rawId === 'string' && !rawId.startsWith('=') ? rawId : undefined;
}
private async activateWorkflow(
workflowId: string,
versionIdToActivate: string,
user: User,
): Promise<void> {
try {
await this.workflowService.activateWorkflow(user, workflowId, {
versionId: versionIdToActivate,
source: 'import',
});
} catch (e) {
this.logger.error(`Failed to activate workflow ${workflowId}`, { error: ensureError(e) });
}
} }
async replaceInvalidCreds(workflow: IWorkflowBase, projectId: string) { async replaceInvalidCreds(workflow: IWorkflowBase, projectId: string) {

View File

@ -8,12 +8,15 @@ import {
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { WorkflowPublishHistoryRepository, WorkflowHistoryRepository } from '@n8n/db'; import { WorkflowPublishHistoryRepository, WorkflowHistoryRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { INodeType } from 'n8n-workflow';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import '@/zod-alias-support'; import '@/zod-alias-support';
import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { ImportWorkflowsCommand } from '@/commands/import/workflow'; import { ImportWorkflowsCommand } from '@/commands/import/workflow';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { NodeTypes } from '@/node-types';
import { WorkflowService } from '@/workflows/workflow.service';
import { setupTestCommand } from '@test-integration/utils/test-command'; import { setupTestCommand } from '@test-integration/utils/test-command';
import { createMember, createOwner } from '../shared/db/users'; import { createMember, createOwner } from '../shared/db/users';
@ -21,6 +24,7 @@ import { createMember, createOwner } from '../shared/db/users';
mockInstance(LoadNodesAndCredentials); mockInstance(LoadNodesAndCredentials);
mockInstance(ActiveWorkflowManager); mockInstance(ActiveWorkflowManager);
mockInstance(WorkflowPublishHistoryRepository); mockInstance(WorkflowPublishHistoryRepository);
const mockNodeTypes = mockInstance(NodeTypes);
const command = setupTestCommand(ImportWorkflowsCommand); const command = setupTestCommand(ImportWorkflowsCommand);
@ -385,6 +389,27 @@ describe('--activeState flag', () => {
globalConfig.executions.mode = originalMode; globalConfig.executions.mode = originalMode;
}); });
// TODO: fix this workaround being needed for these tests to run.
// It was introduced after refactoring the ImportService used by the import command
// from using the ActiveWorkflowManager to activate/deactivate workflows to the WorkflowService.
beforeEach(() => {
// Bypass webhook conflict detection to avoid infrastructure dependencies
// (getWorkflowExecutionData → VariablesService.getAllCached → CacheService/Redis)
jest
.spyOn(Container.get(WorkflowService) as any, '_findConflictingWebhooks')
.mockResolvedValue([]);
mockNodeTypes.getByNameAndVersion.mockImplementation((nodeType) => {
if (nodeType === 'n8n-nodes-base.webhook') {
return {
description: { webhooks: undefined, properties: [] },
webhook: jest.fn(),
} as unknown as INodeType;
}
return { description: { properties: [] } } as unknown as INodeType;
});
});
describe('fromJson', () => { describe('fromJson', () => {
it('should activate a workflow that is marked as active in the imported json', async () => { it('should activate a workflow that is marked as active in the imported json', async () => {
await createOwner(); await createOwner();
@ -410,7 +435,7 @@ describe('--activeState flag', () => {
}); });
it('should deactivate the previously active version and activate the new version when importing a workflow json with an ID that already exists for an active workflow', async () => { it('should deactivate the previously active version and activate the new version when importing a workflow json with an ID that already exists for an active workflow', async () => {
await createOwner(); const owner = await createOwner();
await command.run([ await command.run([
'--input=./test/integration/commands/import-workflows/combined-with-update/original.json', '--input=./test/integration/commands/import-workflows/combined-with-update/original.json',
@ -441,12 +466,12 @@ describe('--activeState flag', () => {
expect(activeWorkflowManager.add).toHaveBeenLastCalledWith('998', 'activate'); expect(activeWorkflowManager.add).toHaveBeenLastCalledWith('998', 'activate');
const publishHistoryRepo = Container.get(WorkflowPublishHistoryRepository); const publishHistoryRepo = Container.get(WorkflowPublishHistoryRepository);
expect(publishHistoryRepo.addRecord).toHaveBeenCalledTimes(2); expect(publishHistoryRepo.addRecord).toHaveBeenCalledTimes(3);
expect(publishHistoryRepo.addRecord).toHaveBeenLastCalledWith({ expect(publishHistoryRepo.addRecord).toHaveBeenLastCalledWith({
workflowId: '998', workflowId: '998',
versionId: second.versionId, versionId: second.versionId,
event: 'activated', event: 'activated',
userId: null, userId: owner.id,
}); });
}); });
}); });

View File

@ -16,16 +16,16 @@ import {
SharedWorkflowRepository, SharedWorkflowRepository,
WorkflowRepository, WorkflowRepository,
WorkflowHistoryRepository, WorkflowHistoryRepository,
WorkflowPublishHistoryRepository, UserRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { INode } from 'n8n-workflow'; import type { INode } from 'n8n-workflow';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service'; import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
import { ImportService } from '@/services/import.service'; import { ImportService } from '@/services/import.service';
import type { WorkflowService } from '@/workflows/workflow.service';
import { createMember, createOwner } from './shared/db/users'; import { createMember, createOwner } from './shared/db/users';
@ -34,13 +34,12 @@ describe('ImportService', () => {
let tagRepository: TagRepository; let tagRepository: TagRepository;
let owner: User; let owner: User;
let ownerPersonalProject: Project; let ownerPersonalProject: Project;
let mockActiveWorkflowManager: ActiveWorkflowManager; let mockWorkflowService: jest.Mocked<WorkflowService>;
let mockWorkflowIndexService: WorkflowIndexService; let mockWorkflowIndexService: WorkflowIndexService;
let workflowRepository: WorkflowRepository; let workflowRepository: WorkflowRepository;
let sharedWorkflowRepository: SharedWorkflowRepository; let sharedWorkflowRepository: SharedWorkflowRepository;
let workflowHistoryRepository: WorkflowHistoryRepository; let workflowHistoryRepository: WorkflowHistoryRepository;
let workflowPublishHistoryRepository: WorkflowPublishHistoryRepository;
beforeAll(async () => { beforeAll(async () => {
await testDb.init(); await testDb.init();
@ -48,7 +47,6 @@ describe('ImportService', () => {
workflowRepository = Container.get(WorkflowRepository); workflowRepository = Container.get(WorkflowRepository);
sharedWorkflowRepository = Container.get(SharedWorkflowRepository); sharedWorkflowRepository = Container.get(SharedWorkflowRepository);
workflowHistoryRepository = Container.get(WorkflowHistoryRepository); workflowHistoryRepository = Container.get(WorkflowHistoryRepository);
workflowPublishHistoryRepository = Container.get(WorkflowPublishHistoryRepository);
owner = await createOwner(); owner = await createOwner();
ownerPersonalProject = await getPersonalProject(owner); ownerPersonalProject = await getPersonalProject(owner);
@ -56,9 +54,9 @@ describe('ImportService', () => {
tagRepository = Container.get(TagRepository); tagRepository = Container.get(TagRepository);
const credentialsRepository = Container.get(CredentialsRepository); const credentialsRepository = Container.get(CredentialsRepository);
const userRepository = Container.get(UserRepository);
mockActiveWorkflowManager = mock<ActiveWorkflowManager>(); mockWorkflowService = mock<WorkflowService>();
mockWorkflowIndexService = mock<WorkflowIndexService>(); mockWorkflowIndexService = mock<WorkflowIndexService>();
importService = new ImportService( importService = new ImportService(
@ -67,11 +65,10 @@ describe('ImportService', () => {
tagRepository, tagRepository,
mock(), mock(),
mock(), mock(),
mockActiveWorkflowManager,
mockWorkflowIndexService, mockWorkflowIndexService,
mock(), mock(),
workflowRepository, userRepository,
workflowPublishHistoryRepository, mockWorkflowService,
); );
}); });
@ -93,7 +90,7 @@ describe('ImportService', () => {
test('should import credless and tagless workflow', async () => { test('should import credless and tagless workflow', async () => {
const workflowToImport = await createWorkflow(); const workflowToImport = await createWorkflow();
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
const dbWorkflow = await getWorkflowById(workflowToImport.id); const dbWorkflow = await getWorkflowById(workflowToImport.id);
@ -106,7 +103,7 @@ describe('ImportService', () => {
test('should make user owner of imported workflow', async () => { test('should make user owner of imported workflow', async () => {
const workflowToImport = newWorkflow(); const workflowToImport = newWorkflow();
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
const dbSharing = await sharedWorkflowRepository.findOneOrFail({ const dbSharing = await sharedWorkflowRepository.findOneOrFail({
where: { where: {
@ -124,7 +121,7 @@ describe('ImportService', () => {
const memberPersonalProject = await getPersonalProject(member); const memberPersonalProject = await getPersonalProject(member);
const workflowToImport = await createWorkflow(undefined, owner); const workflowToImport = await createWorkflow(undefined, owner);
await importService.importWorkflows([workflowToImport], memberPersonalProject.id); await importService.importWorkflows([workflowToImport], memberPersonalProject.id, owner.id, {});
const sharings = await getAllSharedWorkflows(); const sharings = await getAllSharedWorkflows();
@ -140,7 +137,7 @@ describe('ImportService', () => {
test('should deactivate imported workflow if active', async () => { test('should deactivate imported workflow if active', async () => {
const workflowToImport = await createActiveWorkflow(); const workflowToImport = await createActiveWorkflow();
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
const dbWorkflow = await getWorkflowById(workflowToImport.id); const dbWorkflow = await getWorkflowById(workflowToImport.id);
@ -169,7 +166,7 @@ describe('ImportService', () => {
const workflowToImport = await createWorkflow({ nodes }); const workflowToImport = await createWorkflow({ nodes });
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
const dbWorkflow = await getWorkflowById(workflowToImport.id); const dbWorkflow = await getWorkflowById(workflowToImport.id);
@ -189,7 +186,7 @@ describe('ImportService', () => {
const workflowToImport = await createWorkflow({ tags: [tag] }); const workflowToImport = await createWorkflow({ tags: [tag] });
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
const dbWorkflow = await workflowRepository.findOneOrFail({ const dbWorkflow = await workflowRepository.findOneOrFail({
where: { id: workflowToImport.id }, where: { id: workflowToImport.id },
@ -210,7 +207,7 @@ describe('ImportService', () => {
const workflowToImport = await createWorkflow({ tags: [tag] }); const workflowToImport = await createWorkflow({ tags: [tag] });
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
const dbWorkflow = await workflowRepository.findOneOrFail({ const dbWorkflow = await workflowRepository.findOneOrFail({
where: { id: workflowToImport.id }, where: { id: workflowToImport.id },
@ -229,7 +226,7 @@ describe('ImportService', () => {
const workflowToImport = await createWorkflow({ tags: [tag] }); const workflowToImport = await createWorkflow({ tags: [tag] });
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
const dbWorkflow = await workflowRepository.findOneOrFail({ const dbWorkflow = await workflowRepository.findOneOrFail({
where: { id: workflowToImport.id }, where: { id: workflowToImport.id },
@ -245,17 +242,21 @@ describe('ImportService', () => {
expect(dbTag.name).toBe(tag.name); // tag created expect(dbTag.name).toBe(tag.name); // tag created
}); });
test('should remove workflow from ActiveWorkflowManager when workflow has ID', async () => { test('should call WorkflowService.deactivateWorkflow when workflow has ID and is active', async () => {
const workflowWithId = await createActiveWorkflow(); const workflowWithId = await createActiveWorkflow();
await importService.importWorkflows([workflowWithId], ownerPersonalProject.id); await importService.importWorkflows([workflowWithId], ownerPersonalProject.id, owner.id, {});
expect(mockActiveWorkflowManager.remove).toHaveBeenCalledWith(workflowWithId.id); expect(mockWorkflowService.deactivateWorkflow).toHaveBeenCalledWith(
expect.objectContaining({ id: owner.id }),
workflowWithId.id,
{ source: 'import' },
);
}); });
test('should always create a record in workflow history', async () => { test('should always create a record in workflow history', async () => {
const workflowToImport = newWorkflow(); const workflowToImport = newWorkflow();
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
const workflowHistoryRecords = await workflowHistoryRepository.find({ const workflowHistoryRecords = await workflowHistoryRepository.find({
where: { where: {
@ -277,7 +278,7 @@ describe('ImportService', () => {
description: 'Historical workflow description', description: 'Historical workflow description',
}; };
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
const workflowHistoryRecords = await workflowHistoryRepository.find({ const workflowHistoryRecords = await workflowHistoryRepository.find({
where: { where: {
@ -290,46 +291,31 @@ describe('ImportService', () => {
expect(workflowHistoryRecords[0].description).toBe('Historical workflow description'); expect(workflowHistoryRecords[0].description).toBe('Historical workflow description');
}); });
test('should create a record in workflow publish history if active version exists', async () => { test('should call WorkflowService.deactivateWorkflow when re-importing an existing active workflow', async () => {
// Create an existing active workflow in the database first
const existingWorkflow = await createActiveWorkflow(); const existingWorkflow = await createActiveWorkflow();
const originalActiveVersionId = existingWorkflow.activeVersionId!;
// Now import it again (simulating re-import of an active workflow)
const workflowToImport = await getWorkflowById(existingWorkflow.id); const workflowToImport = await getWorkflowById(existingWorkflow.id);
if (!workflowToImport) fail('Expected to find workflow'); if (!workflowToImport) fail('Expected to find workflow');
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
const publishHistoryRecords = await workflowPublishHistoryRepository.find({ expect(mockWorkflowService.deactivateWorkflow).toHaveBeenCalledWith(
where: { expect.objectContaining({ id: owner.id }),
workflowId: existingWorkflow.id, existingWorkflow.id,
event: 'deactivated', { source: 'import' },
}, );
});
// Should have publish history for deactivating the original active version
expect(publishHistoryRecords).toHaveLength(1);
expect(publishHistoryRecords[0].versionId).toBe(originalActiveVersionId);
}); });
test('should not create a record in workflow publish history for new workflows', async () => { test('should not call WorkflowService.deactivateWorkflow for new (non-existing) workflows', async () => {
mockWorkflowService.deactivateWorkflow.mockClear();
const workflowToImport = newWorkflow(); const workflowToImport = newWorkflow();
workflowToImport.active = true; workflowToImport.active = true;
workflowToImport.activeVersionId = 'some-version'; workflowToImport.activeVersionId = 'some-version';
if (!workflowToImport) fail('Expected to find workflow'); await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {});
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled();
const publishHistoryRecords = await workflowPublishHistoryRepository.find({
where: {
workflowId: workflowToImport.id,
event: 'deactivated',
},
});
expect(publishHistoryRecords).toHaveLength(0);
}); });
test('should always generate a new versionId when importing, ensuring proper history ordering', async () => { test('should always generate a new versionId when importing, ensuring proper history ordering', async () => {
@ -340,7 +326,12 @@ describe('ImportService', () => {
const workflowToReimport = await getWorkflowById(initialWorkflow.id); const workflowToReimport = await getWorkflowById(initialWorkflow.id);
if (!workflowToReimport) fail('Expected to find workflow'); if (!workflowToReimport) fail('Expected to find workflow');
await importService.importWorkflows([workflowToReimport], ownerPersonalProject.id); await importService.importWorkflows(
[workflowToReimport],
ownerPersonalProject.id,
owner.id,
{},
);
const historyRecords = await workflowHistoryRepository.find({ const historyRecords = await workflowHistoryRepository.find({
where: { workflowId: initialWorkflow.id }, where: { workflowId: initialWorkflow.id },
@ -361,20 +352,19 @@ describe('ImportService', () => {
const workflowToImport = await createWorkflow(); const workflowToImport = await createWorkflow();
workflowToImport.active = true; workflowToImport.active = true;
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, { await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {
activeState: 'fromJson', activeState: 'fromJson',
}); });
const dbWorkflow = await getWorkflowById(workflowToImport.id); expect(mockWorkflowService.activateWorkflow).toHaveBeenCalledWith(
if (!dbWorkflow) fail('Expected to find workflow'); expect.objectContaining({ id: owner.id }),
workflowToImport.id,
expect(dbWorkflow.active).toBe(true); expect.objectContaining({ source: 'import' }),
expect(dbWorkflow.activeVersionId).toBe(dbWorkflow.versionId); );
expect(mockActiveWorkflowManager.add).toHaveBeenCalledWith(workflowToImport.id, 'activate');
}); });
test('should deactivate imported workflow that is updating existing one when JSON has active=false', async () => { test('should deactivate imported workflow that is updating existing one when JSON has active=false', async () => {
jest.mocked(mockActiveWorkflowManager.add).mockClear(); mockWorkflowService.activateWorkflow.mockClear();
const existingWorkflow = await createActiveWorkflow(); const existingWorkflow = await createActiveWorkflow();
@ -382,7 +372,7 @@ describe('ImportService', () => {
if (!workflowToImport) fail('Expected to find workflow'); if (!workflowToImport) fail('Expected to find workflow');
workflowToImport.active = false; workflowToImport.active = false;
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, { await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {
activeState: 'fromJson', activeState: 'fromJson',
}); });
@ -391,16 +381,16 @@ describe('ImportService', () => {
expect(dbWorkflow.active).toBe(false); expect(dbWorkflow.active).toBe(false);
expect(dbWorkflow.activeVersionId).toBeNull(); expect(dbWorkflow.activeVersionId).toBeNull();
expect(mockActiveWorkflowManager.add).not.toHaveBeenCalled(); expect(mockWorkflowService.activateWorkflow).not.toHaveBeenCalled();
}); });
test('should leave imported workflow deactivated when JSON has active=false', async () => { test('should leave imported workflow deactivated when JSON has active=false', async () => {
jest.mocked(mockActiveWorkflowManager.add).mockClear(); mockWorkflowService.activateWorkflow.mockClear();
const workflowToImport = await createWorkflow(); const workflowToImport = await createWorkflow();
workflowToImport.active = false; workflowToImport.active = false;
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, { await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {
activeState: 'fromJson', activeState: 'fromJson',
}); });
@ -409,60 +399,58 @@ describe('ImportService', () => {
expect(dbWorkflow.active).toBe(false); expect(dbWorkflow.active).toBe(false);
expect(dbWorkflow.activeVersionId).toBeNull(); expect(dbWorkflow.activeVersionId).toBeNull();
expect(mockActiveWorkflowManager.add).not.toHaveBeenCalled(); expect(mockWorkflowService.activateWorkflow).not.toHaveBeenCalled();
}); });
test('should record both deactivated (old) and activated (new) publish history when re-importing an active workflow', async () => { test('should call both deactivateWorkflow and activateWorkflow when re-importing an active workflow', async () => {
mockWorkflowService.deactivateWorkflow.mockClear();
mockWorkflowService.activateWorkflow.mockClear();
const existingWorkflow = await createActiveWorkflow(); const existingWorkflow = await createActiveWorkflow();
const originalActiveVersionId = existingWorkflow.activeVersionId!;
const workflowToImport = await getWorkflowById(existingWorkflow.id); const workflowToImport = await getWorkflowById(existingWorkflow.id);
if (!workflowToImport) fail('Expected to find workflow'); if (!workflowToImport) fail('Expected to find workflow');
workflowToImport.active = true; workflowToImport.active = true;
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, { await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {
activeState: 'fromJson', activeState: 'fromJson',
}); });
const dbWorkflow = await getWorkflowById(existingWorkflow.id); expect(mockWorkflowService.deactivateWorkflow).toHaveBeenCalledWith(
if (!dbWorkflow) fail('Expected to find workflow'); expect.objectContaining({ id: owner.id }),
existingWorkflow.id,
const deactivatedRecords = await workflowPublishHistoryRepository.find({ { source: 'import' },
where: { workflowId: existingWorkflow.id, event: 'deactivated' }, );
}); expect(mockWorkflowService.activateWorkflow).toHaveBeenCalledWith(
const activatedForNewVersion = await workflowPublishHistoryRepository.find({ expect.objectContaining({ id: owner.id }),
where: { existingWorkflow.id,
workflowId: existingWorkflow.id, expect.objectContaining({ source: 'import' }),
event: 'activated', );
versionId: dbWorkflow.versionId,
},
});
expect(deactivatedRecords).toHaveLength(1);
expect(deactivatedRecords[0].versionId).toBe(originalActiveVersionId);
expect(activatedForNewVersion).toHaveLength(1);
expect(activatedForNewVersion[0].userId).toBeNull();
}); });
test('should not call ActiveWorkflowManager.remove for a brand-new active workflow', async () => { test('should not call WorkflowService.deactivateWorkflow for a brand-new active workflow', async () => {
jest.mocked(mockActiveWorkflowManager.remove).mockClear(); mockWorkflowService.deactivateWorkflow.mockClear();
jest.mocked(mockActiveWorkflowManager.add).mockClear(); mockWorkflowService.activateWorkflow.mockClear();
const workflowToImport = await createWorkflow(); const workflowToImport = await createWorkflow();
workflowToImport.active = true; workflowToImport.active = true;
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, { await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {
activeState: 'fromJson', activeState: 'fromJson',
}); });
expect(mockActiveWorkflowManager.remove).not.toHaveBeenCalled(); expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled();
expect(mockActiveWorkflowManager.add).toHaveBeenCalledTimes(1); expect(mockWorkflowService.activateWorkflow).toHaveBeenCalledTimes(1);
expect(mockActiveWorkflowManager.add).toHaveBeenCalledWith(workflowToImport.id, 'activate'); expect(mockWorkflowService.activateWorkflow).toHaveBeenCalledWith(
expect.objectContaining({ id: owner.id }),
workflowToImport.id,
expect.objectContaining({ source: 'import' }),
);
}); });
test('should call ActiveWorkflowManager.remove exactly once when re-importing an active workflow', async () => { test('should call WorkflowService.deactivateWorkflow exactly once when re-importing an active workflow', async () => {
jest.mocked(mockActiveWorkflowManager.remove).mockClear(); mockWorkflowService.deactivateWorkflow.mockClear();
jest.mocked(mockActiveWorkflowManager.add).mockClear(); mockWorkflowService.activateWorkflow.mockClear();
const existingWorkflow = await createActiveWorkflow(); const existingWorkflow = await createActiveWorkflow();
@ -470,14 +458,22 @@ describe('ImportService', () => {
if (!workflowToImport) fail('Expected to find workflow'); if (!workflowToImport) fail('Expected to find workflow');
workflowToImport.active = true; workflowToImport.active = true;
await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, { await importService.importWorkflows([workflowToImport], ownerPersonalProject.id, owner.id, {
activeState: 'fromJson', activeState: 'fromJson',
}); });
expect(mockActiveWorkflowManager.remove).toHaveBeenCalledTimes(1); expect(mockWorkflowService.deactivateWorkflow).toHaveBeenCalledTimes(1);
expect(mockActiveWorkflowManager.remove).toHaveBeenCalledWith(existingWorkflow.id); expect(mockWorkflowService.deactivateWorkflow).toHaveBeenCalledWith(
expect(mockActiveWorkflowManager.add).toHaveBeenCalledTimes(1); expect.objectContaining({ id: owner.id }),
expect(mockActiveWorkflowManager.add).toHaveBeenCalledWith(existingWorkflow.id, 'activate'); existingWorkflow.id,
{ source: 'import' },
);
expect(mockWorkflowService.activateWorkflow).toHaveBeenCalledTimes(1);
expect(mockWorkflowService.activateWorkflow).toHaveBeenCalledWith(
expect.objectContaining({ id: owner.id }),
existingWorkflow.id,
expect.objectContaining({ source: 'import' }),
);
}); });
}); });
}); });

View File

@ -1,7 +1,7 @@
{ {
"name": "@n8n/i18n", "name": "@n8n/i18n",
"type": "module", "type": "module",
"version": "2.25.0", "version": "2.25.1",
"files": [ "files": [
"dist" "dist"
], ],

View File

@ -1239,6 +1239,17 @@
"experiments.instanceAiPromptSuggestionsV2.suggestions.onboardNewHires.prompt": "When a new employee is added to our HR database, send them a welcome email with their first-week schedule, create their Notion onboarding page, and post an intro message in the #welcome Slack channel.", "experiments.instanceAiPromptSuggestionsV2.suggestions.onboardNewHires.prompt": "When a new employee is added to our HR database, send them a welcome email with their first-week schedule, create their Notion onboarding page, and post an intro message in the #welcome Slack channel.",
"experiments.instanceAiPromptSuggestionsV2.suggestions.extractDataFromEmails.label": "Extract data from emails", "experiments.instanceAiPromptSuggestionsV2.suggestions.extractDataFromEmails.label": "Extract data from emails",
"experiments.instanceAiPromptSuggestionsV2.suggestions.extractDataFromEmails.prompt": "When an email arrives in Gmail with a PDF attachment, use Gemini to extract key data such as amounts, dates, or contract terms, save the structured data to a Google Sheet, and move the PDF to the right Google Drive folder.", "experiments.instanceAiPromptSuggestionsV2.suggestions.extractDataFromEmails.prompt": "When an email arrives in Gmail with a PDF attachment, use Gemini to extract key data such as amounts, dates, or contract terms, save the structured data to a Google Sheet, and move the PDF to the right Google Drive folder.",
"experiments.instanceAiWorkflowPreviewSuggestions.emptyState.title": "What do you want to automate?",
"experiments.instanceAiWorkflowPreviewSuggestions.input.placeholder": "Tell me what to build or ask me a question",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.label": "Score my leads",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.prompt": "When a new lead is created in my CRM, enrich it with Lemlist, score it based on fit, then update the lead if qualified and notify the sales team on Slack.",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.label": "Process invoices",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.prompt": "Every morning, scan Gmail for new invoices, use Claude to extract details and cross-check them against purchase orders in Google Sheets, flag any discrepancies for review, and add all payment due dates to Google Calendar automatically.",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.label": "WhatsApp support agent",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.prompt": "When a customer sends a WhatsApp message, use Gemini to match their question against our FAQ in Google Sheets and reply instantly. If it cannot resolve it, create a support ticket in Notion and alert the team on Slack.",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.label": "Schedule social posts",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.prompt": "Every Monday, read this week's content ideas from a Google Sheet, use Gemini to write tailored content then schedule them to post throughout the week.",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.seeAll": "See all",
"experiments.personalizedTemplatesV3.browseAllTemplates": "Browse our template library", "experiments.personalizedTemplatesV3.browseAllTemplates": "Browse our template library",
"experiments.personalizedTemplatesV3.couldntFind": "Need something different?", "experiments.personalizedTemplatesV3.couldntFind": "Need something different?",
"experiments.personalizedTemplatesV3.exploreTemplates": "Get started with HubSpot workflows:", "experiments.personalizedTemplatesV3.exploreTemplates": "Get started with HubSpot workflows:",

View File

@ -1,6 +1,6 @@
{ {
"name": "n8n-editor-ui", "name": "n8n-editor-ui",
"version": "2.25.0", "version": "2.25.1",
"description": "Workflow Editor UI for n8n", "description": "Workflow Editor UI for n8n",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",

View File

@ -118,6 +118,10 @@ export const SURFACE_MCP_TO_NEW_CLOUD_USERS_EXPERIMENT = createExperiment(
export const CANVAS_NODES_GROUPING_EXPERIMENT = createExperiment('083_canvas_nodes_grouping'); export const CANVAS_NODES_GROUPING_EXPERIMENT = createExperiment('083_canvas_nodes_grouping');
export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT = createExperiment(
'087_instance_ai_workflow_preview_suggestions',
);
export const EXPERIMENTS_TO_TRACK = [ export const EXPERIMENTS_TO_TRACK = [
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name, EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
TEMPLATE_ONBOARDING_EXPERIMENT.name, TEMPLATE_ONBOARDING_EXPERIMENT.name,
@ -148,4 +152,5 @@ export const EXPERIMENTS_TO_TRACK = [
FLOATING_CHAT_HUB_PANEL_EXPERIMENT.name, FLOATING_CHAT_HUB_PANEL_EXPERIMENT.name,
SURFACE_MCP_TO_NEW_CLOUD_USERS_EXPERIMENT.name, SURFACE_MCP_TO_NEW_CLOUD_USERS_EXPERIMENT.name,
CANVAS_NODES_GROUPING_EXPERIMENT.name, CANVAS_NODES_GROUPING_EXPERIMENT.name,
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT.name,
]; ];

View File

@ -0,0 +1,517 @@
<script lang="ts" setup>
import { computed, reactive, ref, onMounted, onUnmounted, watch, type Component } from 'vue';
import { useElementSize } from '@vueuse/core';
import type {
PreviewWorkflow,
PreviewWorkflowConnection,
PreviewVisualizationType,
PreviewOutputVisualization,
} from '../workflows/types';
import WorkflowPreviewNode from './WorkflowPreviewNode.vue';
import SlackMessageVisualization from './visualizations/SlackMessageVisualization.vue';
import SalesforceCardVisualization from './visualizations/SalesforceCardVisualization.vue';
import InvoiceSpreadsheetVisualization from './visualizations/InvoiceSpreadsheetVisualization.vue';
import WhatsAppChatVisualization from './visualizations/WhatsAppChatVisualization.vue';
const NODE_HALF_WIDTH = 48;
const EDGE_CURVE_OFFSET = 60;
const PADDING = 80;
const CANVAS_HEIGHT = 420;
const NODE_LABEL_OFFSET = 13;
const NODE_RUNNING_DURATION_MS = 250;
export type NodeAnimationState = 'idle' | 'running' | 'success';
export type AnimationPhase = 'idle' | 'input' | 'nodes' | 'output' | 'done';
const visualizationComponents: Record<PreviewVisualizationType, Component> = {
'slack-message': SlackMessageVisualization,
'salesforce-card': SalesforceCardVisualization,
'invoice-spreadsheet': InvoiceSpreadsheetVisualization,
'whatsapp-chat': WhatsAppChatVisualization,
};
const props = withDefaults(
defineProps<{
workflow: PreviewWorkflow;
animating?: boolean;
}>(),
{ animating: true },
);
const canvasRef = ref<HTMLElement | null>(null);
const { width: canvasRenderedWidth } = useElementSize(canvasRef);
const animationPhase = ref<AnimationPhase>('idle');
const nodeStates = reactive<Record<string, NodeAnimationState>>({});
const hasInputViz = computed(() => !!props.workflow.inputVisualization);
const hasOutputViz = computed(() => !!props.workflow.outputVisualization);
const outputVizItems = computed((): PreviewOutputVisualization[] => {
const viz = props.workflow.outputVisualization;
if (!viz) return [];
if (Array.isArray(viz)) return viz;
const last = lastNode.value;
if (!last) return [];
return [{ type: viz.type, props: viz.props, targetNodeId: last.id }];
});
const inputVizComponent = computed(() =>
props.workflow.inputVisualization
? visualizationComponents[props.workflow.inputVisualization.type]
: undefined,
);
const executionSteps = computed(() => {
const { nodes, connections } = props.workflow;
const incomingMap = new Map<string, Set<string>>();
for (const node of nodes) {
incomingMap.set(node.id, new Set());
}
for (const conn of connections) {
incomingMap.get(conn.target)?.add(conn.source);
}
const visited = new Set<string>();
const steps: string[][] = [];
while (visited.size < nodes.length) {
const ready = nodes
.filter((n) => !visited.has(n.id))
.filter((n) => {
const deps = incomingMap.get(n.id);
return !deps || [...deps].every((d) => visited.has(d));
})
.map((n) => n.id);
if (ready.length === 0) break;
steps.push(ready);
for (const id of ready) visited.add(id);
}
return steps;
});
const firstNode = computed(() => {
const nodes = props.workflow.nodes;
if (nodes.length === 0) return undefined;
return nodes.reduce((min, n) => (n.position.x < min.position.x ? n : min), nodes[0]);
});
const lastNode = computed(() => {
const nodes = props.workflow.nodes;
if (nodes.length === 0) return undefined;
return nodes.reduce((max, n) => (n.position.x > max.position.x ? n : max), nodes[0]);
});
const triggerNodeIds = computed(() => {
const targets = new Set(props.workflow.connections.map((c) => c.target));
return new Set(props.workflow.nodes.filter((n) => !targets.has(n.id)).map((n) => n.id));
});
function resetStates() {
for (const node of props.workflow.nodes) {
nodeStates[node.id] = 'idle';
}
}
let animationTimer: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
animationTimer = setTimeout(resolve, ms);
});
}
let outputCompletedCount = 0;
async function runNodeAnimation() {
for (const step of executionSteps.value) {
if (stopped) return;
for (const id of step) nodeStates[id] = 'running';
await sleep(NODE_RUNNING_DURATION_MS);
if (stopped) return;
for (const id of step) nodeStates[id] = 'success';
}
if (stopped) return;
if (hasOutputViz.value) {
outputCompletedCount = 0;
animationPhase.value = 'output';
} else {
animationPhase.value = 'done';
}
}
function startAnimation() {
stopped = false;
resetStates();
if (hasInputViz.value) {
animationPhase.value = 'input';
} else {
animationPhase.value = 'nodes';
void runNodeAnimation();
}
}
function stopAnimation() {
stopped = true;
if (animationTimer) {
clearTimeout(animationTimer);
animationTimer = null;
}
}
function handleInputComplete() {
if (stopped) return;
animationPhase.value = 'nodes';
void runNodeAnimation();
}
function handleOutputComplete() {
if (stopped) return;
outputCompletedCount++;
if (outputCompletedCount >= outputVizItems.value.length) {
animationPhase.value = 'done';
}
}
watch(
() => props.animating,
(val) => {
if (val) {
startAnimation();
} else {
stopAnimation();
resetStates();
animationPhase.value = 'idle';
}
},
);
onMounted(() => {
resetStates();
if (props.animating) {
startAnimation();
}
});
onUnmounted(stopAnimation);
// CRM icons cycling logic (score-my-leads example)
const crmCycleIndex = ref(0);
const crmCycleVisible = ref(false);
let crmCycleStartTimer: ReturnType<typeof setTimeout> | null = null;
let crmCycleInterval: ReturnType<typeof setInterval> | null = null;
const hasCrmCycle = computed(() => !!props.workflow.crmCycle);
const crmVariants = computed(() => props.workflow.crmCycle?.variants ?? []);
const crmCurrentVariant = computed(() => crmVariants.value[crmCycleIndex.value]);
const crmCycleNodeIds = computed(() => new Set(props.workflow.crmCycle?.nodeIds ?? []));
const CRM_INITIAL_DELAY_MS = 500;
const CRM_INTERVAL_MS = 1400;
function startCrmCycle() {
if (!hasCrmCycle.value) return;
crmCycleVisible.value = true;
crmCycleIndex.value = 0;
const interval = props.workflow.crmCycle?.intervalMs ?? CRM_INTERVAL_MS;
crmCycleStartTimer = setTimeout(() => {
crmCycleIndex.value = (crmCycleIndex.value + 1) % crmVariants.value.length;
crmCycleInterval = setInterval(() => {
crmCycleIndex.value = (crmCycleIndex.value + 1) % crmVariants.value.length;
}, interval);
}, CRM_INITIAL_DELAY_MS);
}
function stopCrmCycle() {
crmCycleVisible.value = false;
if (crmCycleStartTimer) {
clearTimeout(crmCycleStartTimer);
crmCycleStartTimer = null;
}
if (crmCycleInterval) {
clearInterval(crmCycleInterval);
crmCycleInterval = null;
}
}
watch(animationPhase, (phase) => {
if (phase === 'done' && hasCrmCycle.value) {
startCrmCycle();
} else {
stopCrmCycle();
}
});
onUnmounted(stopCrmCycle);
const inputVizIcon = computed(() => {
if (crmCycleVisible.value && crmCurrentVariant.value) {
return crmCurrentVariant.value.icon.src;
}
return undefined;
});
const bounds = computed(() => {
const nodes = props.workflow.nodes;
if (nodes.length === 0) return { minX: 0, minY: 0, width: 400, height: 200 };
const xs = nodes.map((n) => n.position.x);
const ys = nodes.map((n) => n.position.y);
const minX = Math.min(...xs) - PADDING;
const minY = Math.min(...ys) - PADDING;
const maxX = Math.max(...xs) + PADDING;
const maxY = Math.max(...ys) + PADDING;
return { minX, minY, width: maxX - minX, height: maxY - minY };
});
const CANVAS_WIDTH = 1600;
const VIZ_MARGIN = 160;
const VIZ_GAP = 28;
const CANVAS_INNER_PADDING = 60;
const scale = computed(() => {
const { width, height } = bounds.value;
let availableWidth = CANVAS_WIDTH - 2 * CANVAS_INNER_PADDING;
if (hasInputViz.value) availableWidth -= VIZ_MARGIN;
if (hasOutputViz.value) availableWidth -= VIZ_MARGIN;
const scaleX = availableWidth / width;
const scaleY = (CANVAS_HEIGHT - 2 * CANVAS_INNER_PADDING) / height;
return Math.min(scaleX, scaleY, 1);
});
const effectiveCanvasWidth = computed(() =>
canvasRenderedWidth.value > 0 ? canvasRenderedWidth.value : CANVAS_WIDTH,
);
const containerStyle = computed(() => {
const { width, height } = bounds.value;
const s = scale.value;
const scaledWidth = width * s;
const scaledHeight = height * s;
return {
width: `${width}px`,
height: `${height}px`,
transform: `scale(${s})`,
transformOrigin: 'top left',
left: `${(effectiveCanvasWidth.value - scaledWidth) / 2}px`,
top: `${(CANVAS_HEIGHT - scaledHeight) / 2}px`,
};
});
const viewBox = computed(
() => `${bounds.value.minX} ${bounds.value.minY} ${bounds.value.width} ${bounds.value.height}`,
);
const canvasMarginLeft = computed(() => {
const s = scale.value;
const scaledWidth = bounds.value.width * s;
return (effectiveCanvasWidth.value - scaledWidth) / 2;
});
const canvasMarginTop = computed(() => {
const s = scale.value;
const scaledHeight = bounds.value.height * s;
return (CANVAS_HEIGHT - scaledHeight) / 2;
});
function toScreenX(workflowX: number): number {
return (workflowX - bounds.value.minX) * scale.value + canvasMarginLeft.value;
}
function toScreenY(workflowY: number): number {
return (workflowY - bounds.value.minY) * scale.value + canvasMarginTop.value;
}
const inputSlotStyle = computed(() => {
if (!firstNode.value) return {};
const nodeScreenX = toScreenX(firstNode.value.position.x - NODE_HALF_WIDTH);
const nodeScreenY = toScreenY(firstNode.value.position.y - NODE_LABEL_OFFSET);
return {
left: `${nodeScreenX - VIZ_GAP}px`,
top: `${nodeScreenY}px`,
transform: 'translateX(-100%) translateY(-50%)',
};
});
const OUTPUT_VIZ_HEIGHT = 80;
const OUTPUT_VIZ_GAP = 16;
const outputSlotStyles = computed(() => {
const items = outputVizItems.value;
if (items.length === 0) return [];
const positions = items.map((item) => {
const node = props.workflow.nodes.find((n) => n.id === item.targetNodeId);
const targetNode = node ?? lastNode.value;
if (!targetNode) return { x: 0, y: 0 };
return {
x: toScreenX(targetNode.position.x + NODE_HALF_WIDTH),
y: toScreenY(targetNode.position.y - NODE_LABEL_OFFSET),
};
});
if (items.length > 1) {
const sorted = positions.map((p, i) => ({ idx: i, y: p.y })).sort((a, b) => a.y - b.y);
const minSpacing = OUTPUT_VIZ_HEIGHT + OUTPUT_VIZ_GAP;
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1];
const curr = sorted[i];
if (curr.y - prev.y < minSpacing) {
positions[curr.idx].y = positions[prev.idx].y + minSpacing;
}
}
}
const isMulti = items.length > 1;
return positions.map((pos) => ({
left: `${pos.x + VIZ_GAP}px`,
top: `${pos.y}px`,
...(isMulti ? {} : { transform: 'translateY(-50%)' }),
}));
});
function getEdgePath(connection: PreviewWorkflowConnection): string {
const sourceNode = props.workflow.nodes.find((n) => n.id === connection.source);
const targetNode = props.workflow.nodes.find((n) => n.id === connection.target);
if (!sourceNode || !targetNode) return '';
const sx = sourceNode.position.x + NODE_HALF_WIDTH;
const sy = sourceNode.position.y - NODE_LABEL_OFFSET;
const tx = targetNode.position.x - NODE_HALF_WIDTH;
const ty = targetNode.position.y - NODE_LABEL_OFFSET;
const dist = Math.abs(tx - sx);
const cx = Math.min(EDGE_CURVE_OFFSET, dist * 0.4);
return `M ${sx} ${sy} C ${sx + cx} ${sy}, ${tx - cx} ${ty}, ${tx} ${ty}`;
}
function isEdgeSuccess(connection: PreviewWorkflowConnection): boolean {
return nodeStates[connection.target] === 'success';
}
</script>
<template>
<div ref="canvasRef" :class="$style.canvas">
<div :class="$style.viewport" :style="containerStyle">
<svg :class="$style.edges" :viewBox="viewBox">
<path
v-for="(conn, idx) in props.workflow.connections"
:key="`edge-${idx}`"
:d="getEdgePath(conn)"
:class="[$style.edge, isEdgeSuccess(conn) && $style.edgeSuccess]"
/>
</svg>
<div :class="$style.nodesLayer">
<WorkflowPreviewNode
v-for="node in props.workflow.nodes"
:key="node.id"
:node="node"
:state="nodeStates[node.id] ?? 'idle'"
:trigger="triggerNodeIds.has(node.id)"
:offset-x="bounds.minX"
:offset-y="bounds.minY"
:icon-override="
crmCycleNodeIds.has(node.id) && crmCycleVisible ? crmCurrentVariant?.icon : undefined
"
/>
</div>
</div>
<div v-if="inputVizComponent" :class="$style.vizSlot" :style="inputSlotStyle">
<component
:is="inputVizComponent"
:active="animationPhase !== 'idle'"
v-bind="props.workflow.inputVisualization?.props"
:icon-override="inputVizIcon"
slide-from="left"
@complete="handleInputComplete"
/>
</div>
<div
v-for="(outputViz, idx) in outputVizItems"
:key="`output-viz-${idx}`"
:class="[$style.vizSlot, outputVizItems.length > 1 && $style.vizSlotCompact]"
:style="outputSlotStyles[idx]"
>
<component
:is="visualizationComponents[outputViz.type]"
:active="animationPhase === 'output' || animationPhase === 'done'"
v-bind="outputViz.props"
:icon-override="
outputViz.type === 'salesforce-card' && crmCycleVisible ? inputVizIcon : undefined
"
@complete="handleOutputComplete"
/>
</div>
</div>
</template>
<style lang="scss" module>
.canvas {
position: relative;
width: 100%;
max-width: 1600px;
height: 420px;
margin: 0 auto;
overflow: hidden;
background-color: var(--canvas--color--background);
background-image: radial-gradient(
oklch(from var(--canvas--dot--color) l c h / 0.5) 1px,
transparent 1px
);
background-size: 16px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius--xl);
}
.viewport {
position: absolute;
}
.edges {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.edge {
fill: none;
stroke: var(--color--foreground--shade-1);
stroke-width: 2;
stroke-linecap: round;
transition: stroke 0.3s ease;
}
.edgeSuccess {
stroke: var(--color--success);
}
.nodesLayer {
position: absolute;
inset: 0;
pointer-events: none;
}
.vizSlot {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.vizSlotCompact {
transform: translateY(-50%);
transform-origin: left center;
}
</style>

View File

@ -0,0 +1,188 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { N8nIcon } from '@n8n/design-system';
import type { IconName } from '@n8n/design-system';
import type { PreviewWorkflowNode, PreviewWorkflowNodeIcon } from '../workflows/types';
import type { NodeAnimationState } from './WorkflowPreviewCanvas.vue';
const props = withDefaults(
defineProps<{
node: PreviewWorkflowNode;
offsetX: number;
offsetY: number;
state?: NodeAnimationState;
trigger?: boolean;
iconOverride?: PreviewWorkflowNodeIcon;
}>(),
{ state: 'idle', trigger: false },
);
const style = computed(() => ({
left: `${props.node.position.x - props.offsetX}px`,
top: `${props.node.position.y - props.offsetY}px`,
}));
const iconWrapperStyle = computed(() => {
if (props.node.iconColor) {
return { color: `var(--node--icon--color--${props.node.iconColor})` };
}
return {};
});
const activeIcon = computed(() => props.iconOverride ?? props.node.icon);
const iconName = computed(() =>
activeIcon.value.type === 'icon' ? (activeIcon.value.name as IconName) : undefined,
);
const iconSrc = computed(() =>
activeIcon.value.type === 'file' ? activeIcon.value.src : undefined,
);
</script>
<template>
<div
:class="[
$style.node,
props.trigger && $style.trigger,
props.state === 'running' && $style.running,
props.state === 'success' && $style.success,
]"
:style="style"
>
<div :class="$style.iconWrapper" :style="iconWrapperStyle">
<N8nIcon v-if="iconName" :icon="iconName" :size="48" />
<Transition v-else-if="iconSrc" :name="$style.swipe" mode="out-in">
<img :key="iconSrc" :src="iconSrc" :class="$style.iconImage" />
</Transition>
<span v-else :class="$style.iconFallback">{{ props.node.label.charAt(0) }}</span>
</div>
<span :class="$style.label">{{ props.node.label }}</span>
</div>
</template>
<style lang="scss" module>
.node {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
transform: translate(-50%, -50%);
}
.iconWrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 96px;
border-radius: var(--radius--lg);
background: var(--node--color--background, var(--color--background--light-3));
border: 1.5px solid
light-dark(
oklch(from var(--color--neutral-black) l c h / 0.1),
oklch(from var(--color--neutral-white) l c h / 0.15)
);
color: var(--node--icon--color, var(--color--foreground--shade-1));
transition: border-color 0.2s ease;
&::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 10px;
z-index: -1;
opacity: 0;
background: conic-gradient(
from var(--node--gradient-angle),
rgba(255, 109, 90, 1),
rgba(255, 109, 90, 1) 20%,
rgba(255, 109, 90, 0.2) 35%,
rgba(255, 109, 90, 0.2) 65%,
rgba(255, 109, 90, 1) 90%,
rgba(255, 109, 90, 1)
);
transition: opacity 0.15s ease;
}
}
.trigger .iconWrapper {
border-radius: 36px var(--radius--lg) var(--radius--lg) 36px;
&::after {
border-radius: 36px 10px 10px 36px;
}
}
.running .iconWrapper {
border-color: transparent;
&::after {
opacity: 1;
animation: border-rotate 1.5s linear infinite;
}
}
.success .iconWrapper {
border-width: 2px;
border-color: var(--color--success);
}
.iconImage {
max-width: 48px;
max-height: 48px;
width: auto;
height: auto;
}
.iconFallback {
font-size: var(--font-size--2xl);
font-weight: var(--font-weight--bold);
}
.label {
margin-top: var(--spacing--2xs);
font-size: var(--font-size--md);
font-weight: var(--font-weight--medium);
line-height: var(--line-height--sm);
color: var(--color--text--base);
white-space: nowrap;
max-width: 192px;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.swipe:global(-enter-active),
.swipe:global(-leave-active) {
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
.swipe:global(-enter-from) {
transform: translateX(16px);
opacity: 0;
}
.swipe:global(-leave-to) {
transform: translateX(-16px);
opacity: 0;
}
@property --node--gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@keyframes border-rotate {
from {
--node--gradient-angle: 0deg;
}
to {
--node--gradient-angle: 360deg;
}
}
</style>

View File

@ -0,0 +1,191 @@
<script lang="ts" setup>
import { N8nIcon } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { onUnmounted, ref } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { type WorkflowPreviewSuggestion } from '../suggestions';
const PREVIEW_HOVER_DELAY_MS = 300;
const props = defineProps<{
suggestions: readonly WorkflowPreviewSuggestion[];
disabled: boolean;
}>();
interface SubmitSuggestionPayload {
promptKey: BaseTextKey;
suggestionId: string;
suggestionKind: 'prompt';
position: number;
}
const emit = defineEmits<{
'preview-change': [promptKey: BaseTextKey | null];
'submit-suggestion': [payload: SubmitSuggestionPayload];
'workflow-preview': [workflowFile: string | null];
}>();
const i18n = useI18n();
const telemetry = useTelemetry();
const activePreview = ref<string | null>(null);
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
const hoverStartTimes = new Map<string, number>();
function clearHoverTimer() {
if (!hoverTimer) return;
clearTimeout(hoverTimer);
hoverTimer = null;
}
function trackHoverEnd(suggestionId: string) {
const startTime = hoverStartTimes.get(suggestionId);
if (startTime === undefined) return;
hoverStartTimes.delete(suggestionId);
telemetry.track('AI Assistant suggestion button hovered', {
suggestion_id: suggestionId,
seconds: Math.floor((Date.now() - startTime) / 1000),
});
}
function clearPreview() {
clearHoverTimer();
for (const id of hoverStartTimes.keys()) {
trackHoverEnd(id);
}
activePreview.value = null;
emit('preview-change', null);
emit('workflow-preview', null);
}
function handleSuggestionEnter(suggestion: WorkflowPreviewSuggestion) {
if (props.disabled) return;
hoverStartTimes.set(suggestion.id, Date.now());
clearHoverTimer();
hoverTimer = setTimeout(() => {
hoverTimer = null;
activePreview.value = suggestion.id;
emit('preview-change', suggestion.promptKey);
emit('workflow-preview', suggestion.workflowFile);
}, PREVIEW_HOVER_DELAY_MS);
}
function handleSuggestionFocus(suggestion: WorkflowPreviewSuggestion) {
if (props.disabled) return;
clearHoverTimer();
activePreview.value = suggestion.id;
emit('preview-change', suggestion.promptKey);
emit('workflow-preview', suggestion.workflowFile);
}
function handleSuggestionClick(suggestion: WorkflowPreviewSuggestion) {
if (props.disabled) return;
const position = props.suggestions.indexOf(suggestion) + 1;
telemetry.track('AI Assistant suggestion button clicked', {
suggestion_id: suggestion.id,
});
clearPreview();
emit('submit-suggestion', {
promptKey: suggestion.promptKey,
suggestionId: suggestion.id,
suggestionKind: 'prompt',
position,
});
}
onUnmounted(clearPreview);
</script>
<template>
<div :class="$style.suggestions" data-test-id="instance-ai-workflow-preview-suggestions">
<div :class="$style.suggestionRow">
<button
v-for="(suggestion, index) in props.suggestions"
:key="suggestion.id"
type="button"
:class="[
$style.suggestionButton,
activePreview === suggestion.id && $style.suggestionButtonActive,
]"
:style="{ animationDelay: `${index * 50}ms` }"
:data-test-id="`instance-ai-suggestion-${suggestion.id}`"
:disabled="props.disabled"
@click="handleSuggestionClick(suggestion)"
@mouseenter="handleSuggestionEnter(suggestion)"
@mouseleave="clearPreview"
@focus="handleSuggestionFocus(suggestion)"
@blur="clearPreview"
>
<N8nIcon :icon="suggestion.icon" :size="12" :class="$style.suggestionIcon" />
<span>{{ i18n.baseText(suggestion.labelKey) }}</span>
</button>
<a
href="https://n8n.io/workflows/"
target="_blank"
rel="noopener noreferrer"
:class="$style.seeAllLink"
>
<span>{{
i18n.baseText(
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.seeAll' as BaseTextKey,
)
}}</span>
<N8nIcon icon="arrow-right" :size="12" />
</a>
</div>
</div>
</template>
<style module lang="scss">
@use '@/features/ai/shared/styles/prompt-suggestion-buttons' as promptSuggestions;
.suggestions {
width: 100%;
}
.suggestionRow {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing--2xs);
width: 100%;
}
.suggestionButton {
@include promptSuggestions.prompt-suggestion-button;
flex: 0 0 auto;
white-space: nowrap;
}
.suggestionButtonActive {
@include promptSuggestions.prompt-suggestion-button-active;
}
.suggestionIcon {
@include promptSuggestions.prompt-suggestion-icon;
.suggestionButton:hover &,
.suggestionButton:focus-visible & {
opacity: 1;
}
}
.seeAllLink {
display: inline-flex;
align-items: center;
gap: 5px;
flex: 0 0 auto;
white-space: nowrap;
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
</style>

View File

@ -0,0 +1,235 @@
<script lang="ts" setup>
import { ref, watch, onUnmounted } from 'vue';
const APPEAR_DELAY_MS = 200;
const ROW_DELAY_MS = 450;
const CHECK_DELAY_MS = ROW_DELAY_MS + 350;
const COMPLETE_DELAY_MS = CHECK_DELAY_MS + 650;
const props = defineProps<{ active: boolean }>();
const emit = defineEmits<{ complete: [] }>();
const visible = ref(false);
const rowVisible = ref(false);
const checkVisible = ref(false);
let timers: Array<ReturnType<typeof setTimeout>> = [];
function clearTimers() {
for (const t of timers) clearTimeout(t);
timers = [];
}
function runAnimation() {
visible.value = false;
rowVisible.value = false;
checkVisible.value = false;
const base = APPEAR_DELAY_MS;
timers.push(
setTimeout(() => {
visible.value = true;
}, base),
);
timers.push(
setTimeout(() => {
rowVisible.value = true;
}, base + ROW_DELAY_MS),
);
timers.push(
setTimeout(() => {
checkVisible.value = true;
}, base + CHECK_DELAY_MS),
);
timers.push(
setTimeout(() => {
emit('complete');
}, base + COMPLETE_DELAY_MS),
);
}
watch(
() => props.active,
(val) => {
if (val) {
runAnimation();
} else {
clearTimers();
visible.value = false;
rowVisible.value = false;
checkVisible.value = false;
}
},
{ immediate: true },
);
onUnmounted(clearTimers);
</script>
<template>
<div :class="[$style.card, visible && $style.cardVisible]">
<div :class="$style.table">
<div :class="[$style.row, $style.rowHeader]">
<span :class="[$style.cell, $style.th]">Invoice</span>
<span :class="[$style.cell, $style.th]">Date</span>
<span :class="[$style.cell, $style.th]">Discrepancy</span>
</div>
<div :class="$style.row">
<span :class="$style.cell">INV-2024-045</span>
<span :class="$style.cell">May 14</span>
<span :class="[$style.cell, $style.tdMuted]"></span>
</div>
<div :class="$style.row">
<span :class="$style.cell">INV-2024-046</span>
<span :class="$style.cell">May 21</span>
<span :class="[$style.cell, $style.tdMuted]"></span>
</div>
<div :class="[$style.rowCollapse, rowVisible && $style.rowCollapseOpen]">
<div :class="[$style.row, $style.rowLast]">
<span :class="$style.cell">INV-2024-047</span>
<span :class="$style.cell">May 29</span>
<span :class="[$style.cell, $style.tdDiscrepancy]">
<svg
v-if="checkVisible"
viewBox="0 0 18 18"
width="13"
height="13"
:class="$style.checkIcon"
>
<path
d="M2.5 9.5 L7 14 L15.5 4"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
:class="$style.checkPath"
/>
</svg>
</span>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" module>
$border: light-dark(
oklch(from var(--color--neutral-black) l c h / 0.1),
oklch(from var(--color--neutral-white) l c h / 0.12)
);
.card {
width: 280px;
padding: 0;
background: var(--color--background--light-3);
border-radius: var(--radius--lg);
border: 1px solid $border;
display: flex;
flex-direction: column;
opacity: 0;
transform: translateX(8px);
transition:
opacity 0.3s ease,
transform 0.3s ease;
overflow: hidden;
}
.cardVisible {
opacity: 1;
transform: translateX(0);
}
.table {
width: 100%;
}
.row {
display: grid;
grid-template-columns: 2fr 1.2fr 1.3fr;
}
.rowHeader {
background: light-dark(
oklch(from var(--color--neutral-black) l c h / 0.04),
oklch(from var(--color--neutral-white) l c h / 0.06)
);
}
.rowCollapse {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.rowCollapseOpen {
grid-template-rows: 1fr;
}
.rowLast {
min-height: 0;
overflow: hidden;
}
.cell {
font-size: var(--font-size--2xs);
line-height: 13px;
color: var(--color--text--base);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 6px 9px;
border-right: 1px solid $border;
border-bottom: 1px solid $border;
&:last-child {
border-right: none;
}
}
.row:last-of-type .cell {
border-bottom: none;
}
.rowLast .cell {
border-bottom: none;
}
.th {
font-size: 10px;
font-weight: var(--font-weight--bold);
color: var(--color--text--tint-1);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tdMuted {
color: var(--color--text--tint-1);
}
.tdDiscrepancy {
display: flex;
align-items: center;
color: var(--color--warning);
}
.checkIcon {
overflow: visible;
flex-shrink: 0;
}
.checkPath {
stroke-dasharray: 20;
stroke-dashoffset: 20;
animation: draw-check 0.45s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes draw-check {
to {
stroke-dashoffset: 0;
}
}
</style>

View File

@ -0,0 +1,156 @@
<script lang="ts" setup>
import { SALESFORCE_ICON_SVG } from '../../workflows/score-my-leads';
import { computed, ref, watch, onUnmounted } from 'vue';
const APPEAR_DELAY_MS = 200;
const COMPLETE_DELAY_MS = 600;
const props = withDefaults(
defineProps<{
active: boolean;
title?: string;
subtitle?: string;
slideFrom?: 'left' | 'right';
icon?: string;
iconOverride?: string;
}>(),
{
title: 'New lead',
subtitle: 'John Doe',
slideFrom: 'right',
},
);
const currentIcon = computed(() => props.iconOverride ?? props.icon ?? SALESFORCE_ICON_SVG);
const emit = defineEmits<{
complete: [];
}>();
const visible = ref(false);
let timers: Array<ReturnType<typeof setTimeout>> = [];
function clearTimers() {
for (const t of timers) clearTimeout(t);
timers = [];
}
function runAnimation() {
visible.value = false;
timers.push(
setTimeout(() => {
visible.value = true;
}, APPEAR_DELAY_MS),
);
timers.push(
setTimeout(() => {
emit('complete');
}, APPEAR_DELAY_MS + COMPLETE_DELAY_MS),
);
}
watch(
() => props.active,
(val) => {
if (val) runAnimation();
else clearTimers();
},
{ immediate: true },
);
onUnmounted(clearTimers);
</script>
<template>
<div
:class="[
$style.card,
visible && $style.cardVisible,
props.slideFrom === 'left' && $style.slideLeft,
]"
>
<Transition :name="$style.swipe" mode="out-in">
<img :key="currentIcon" :class="$style.icon" :src="currentIcon" alt="" />
</Transition>
<div :class="$style.content">
<span :class="$style.title">{{ props.title }}</span>
<span :class="$style.subtitle">{{ props.subtitle }}</span>
</div>
</div>
</template>
<style lang="scss" module>
.card {
width: 280px;
padding: var(--spacing--sm) var(--spacing--md);
background: var(--color--background--light-3);
border-radius: var(--radius--lg);
border: 1px solid
light-dark(
oklch(from var(--color--neutral-black) l c h / 0.08),
oklch(from var(--color--neutral-white) l c h / 0.12)
);
display: flex;
align-items: center;
gap: var(--spacing--sm);
opacity: 0;
transform: translateX(8px);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.slideLeft {
transform: translateX(-8px);
}
.cardVisible {
opacity: 1;
transform: translateX(0);
}
.icon {
width: 36px;
height: 36px;
flex-shrink: 0;
}
.swipe:global(-enter-active),
.swipe:global(-leave-active) {
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
.swipe:global(-enter-from) {
transform: translateX(16px);
opacity: 0;
}
.swipe:global(-leave-to) {
transform: translateX(-16px);
opacity: 0;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
min-width: 0;
}
.title {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
color: var(--color--text--base);
line-height: 1.4;
}
.subtitle {
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
line-height: 1.4;
}
</style>

View File

@ -0,0 +1,136 @@
<script lang="ts" setup>
import { ref, watch, onUnmounted } from 'vue';
const APPEAR_DELAY_MS = 200;
const COMPLETE_DELAY_MS = 600;
const props = withDefaults(
defineProps<{
active: boolean;
sender?: string;
message?: string;
}>(),
{
sender: 'n8n Bot',
message: 'Urgent ticket: Login page broken',
},
);
const emit = defineEmits<{
complete: [];
}>();
const visible = ref(false);
let timers: Array<ReturnType<typeof setTimeout>> = [];
function clearTimers() {
for (const t of timers) clearTimeout(t);
timers = [];
}
function runAnimation() {
visible.value = false;
timers.push(
setTimeout(() => {
visible.value = true;
}, APPEAR_DELAY_MS),
);
timers.push(
setTimeout(() => {
emit('complete');
}, APPEAR_DELAY_MS + COMPLETE_DELAY_MS),
);
}
watch(
() => props.active,
(val) => {
if (val) runAnimation();
else clearTimers();
},
{ immediate: true },
);
onUnmounted(clearTimers);
</script>
<template>
<div :class="[$style.card, visible && $style.cardVisible]">
<div :class="$style.header">
<div :class="$style.avatar">
<img
:class="$style.avatarIcon"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill='%23fff' fill-rule='evenodd' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 150.852 150.852'%3E%3Cuse xlink:href='%23a' x='.926' y='.926'/%3E%3Csymbol id='a' overflow='visible'%3E%3Cg stroke-width='1.852'%3E%3Cpath fill='%23e01e5a' stroke='%23e01e5a' d='M40.741 93.55c0-8.735 6.607-15.772 14.815-15.772s14.815 7.037 14.815 15.772v38.824c0 8.737-6.607 15.774-14.815 15.774s-14.815-7.037-14.815-15.772z'/%3E%3Cpath fill='%23ecb22d' stroke='%23ecb22d' d='M93.55 107.408c-8.735 0-15.772-6.607-15.772-14.815s7.037-14.815 15.772-14.815h38.826c8.735 0 15.772 6.607 15.772 14.815s-7.037 14.815-15.772 14.815z'/%3E%3Cpath fill='%232fb67c' stroke='%232fb67c' d='M77.778 15.772C77.778 7.037 84.385 0 92.593 0s14.815 7.037 14.815 15.772v38.826c0 8.735-6.607 15.772-14.815 15.772s-14.815-7.037-14.815-15.772z'/%3E%3Cpath fill='%2336c5f1' stroke='%2336c5f1' d='M15.772 70.371C7.037 70.371 0 63.763 0 55.556s7.037-14.815 15.772-14.815h38.826c8.735 0 15.772 6.607 15.772 14.815s-7.037 14.815-15.772 14.815z'/%3E%3Cg stroke-linejoin='miter'%3E%3Cpath fill='%23ecb22d' stroke='%23ecb22d' d='M77.778 133.333c0 8.208 6.607 14.815 14.815 14.815s14.815-6.607 14.815-14.815-6.607-14.815-14.815-14.815H77.778z'/%3E%3Cpath fill='%232fb67c' stroke='%232fb67c' d='M133.334 70.371h-14.815V55.556c0-8.207 6.607-14.815 14.815-14.815s14.815 6.607 14.815 14.815-6.607 14.815-14.815 14.815z'/%3E%3Cpath fill='%23e01e5a' stroke='%23e01e5a' d='M14.815 77.778H29.63v14.815c0 8.207-6.607 14.815-14.815 14.815S0 100.8 0 92.593s6.607-14.815 14.815-14.815z'/%3E%3Cpath fill='%2336c5f1' stroke='%2336c5f1' d='M70.371 14.815V29.63H55.556c-8.207 0-14.815-6.607-14.815-14.815S47.348 0 55.556 0s14.815 6.607 14.815 14.815z'/%3E%3C/g%3E%3C/g%3E%3C/symbol%3E%3C/svg%3E"
alt=""
/>
</div>
<span :class="$style.sender">{{ props.sender }}</span>
</div>
<p :class="$style.message">{{ props.message }}</p>
</div>
</template>
<style lang="scss" module>
.card {
width: 280px;
padding: var(--spacing--sm) var(--spacing--md);
background: var(--color--background--light-3);
border-radius: var(--radius--lg);
border: 1px solid
light-dark(
oklch(from var(--color--neutral-black) l c h / 0.08),
oklch(from var(--color--neutral-white) l c h / 0.12)
);
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
opacity: 0;
transform: translateX(8px);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.cardVisible {
opacity: 1;
transform: translateX(0);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.avatar {
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--color--neutral-700);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
}
.avatarIcon {
width: 100%;
height: 100%;
}
.sender {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
color: var(--color--text--base);
}
.message {
margin: 0;
font-size: var(--font-size--sm);
color: var(--color--text--tint-1);
line-height: 1.5;
}
</style>

View File

@ -0,0 +1,230 @@
<script lang="ts" setup>
import { ref, watch, onUnmounted } from 'vue';
const APPEAR_DELAY_MS = 200;
const BUBBLE_DELAY_MS = 350;
const COMPLETE_DELAY_MS = BUBBLE_DELAY_MS + 700;
const WHATSAPP_ICON =
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2260%22%20height%3D%2260%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22m4.868%2043.303%202.694-9.835a18.94%2018.94%200%200%201-2.535-9.489C5.032%2013.514%2013.548%205%2024.014%205a18.87%2018.87%200%200%201%2013.43%205.566A18.87%2018.87%200%200%201%2043%2023.994c-.004%2010.465-8.522%2018.98-18.986%2018.98h-.008a19%2019%200%200%201-9.073-2.311z%22/%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M4.868%2043.803a.5.5%200%200%201-.482-.631l2.639-9.636a19.5%2019.5%200%200%201-2.497-9.556C4.532%2013.238%2013.273%204.5%2024.014%204.5a19.37%2019.37%200%200%201%2013.784%205.713A19.36%2019.36%200%200%201%2043.5%2023.994c-.004%2010.741-8.746%2019.48-19.486%2019.48a19.54%2019.54%200%200%201-9.144-2.277l-9.875%202.589a.5.5%200%200%201-.127.017%22/%3E%3Cpath%20fill%3D%22%23cfd8dc%22%20d%3D%22M24.014%205a18.87%2018.87%200%200%201%2013.43%205.566A18.87%2018.87%200%200%201%2043%2023.994c-.004%2010.465-8.522%2018.98-18.986%2018.98h-.008a19%2019%200%200%201-9.073-2.311l-10.065%202.64%202.694-9.835a18.94%2018.94%200%200%201-2.535-9.489C5.032%2013.514%2013.548%205%2024.014%205m0-1C12.998%204%204.032%2012.962%204.027%2023.979a20%2020%200%200%200%202.461%209.622L3.903%2043.04a.998.998%200%200%200%201.219%201.231l9.687-2.54a20%2020%200%200%200%209.197%202.244c11.024%200%2019.99-8.963%2019.995-19.98A19.86%2019.86%200%200%200%2038.153%209.86%2019.87%2019.87%200%200%200%2024.014%204%22/%3E%3Cpath%20fill%3D%22%2340c351%22%20d%3D%22M35.176%2012.832a15.67%2015.67%200%200%200-11.157-4.626c-8.704%200-15.783%207.076-15.787%2015.774a15.74%2015.74%200%200%200%202.413%208.396l.376.597-1.595%205.821%205.973-1.566.577.342a15.75%2015.75%200%200%200%208.032%202.199h.006c8.698%200%2015.777-7.077%2015.78-15.776a15.68%2015.68%200%200%200-4.618-11.161%22/%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M19.268%2016.045c-.355-.79-.729-.806-1.068-.82-.277-.012-.593-.011-.909-.011s-.83.119-1.265.594-1.661%201.622-1.661%203.956%201.7%204.59%201.937%204.906%203.282%205.259%208.104%207.161c4.007%201.58%204.823%201.266%205.693%201.187s2.807-1.147%203.202-2.255.395-2.057.277-2.255c-.119-.198-.435-.316-.909-.554s-2.807-1.385-3.242-1.543-.751-.237-1.068.238c-.316.474-1.225%201.543-1.502%201.859s-.554.357-1.028.119-2.002-.738-3.815-2.354c-1.41-1.257-2.362-2.81-2.639-3.285-.277-.474-.03-.731.208-.968.213-.213.474-.554.712-.831.237-.277.316-.475.474-.791s.079-.594-.04-.831c-.117-.238-1.039-2.584-1.461-3.522%22/%3E%3C/svg%3E';
const props = withDefaults(
defineProps<{
active: boolean;
sender?: string;
message?: string;
isOutgoing?: boolean;
slideFrom?: 'left' | 'right';
}>(),
{
sender: 'Customer',
message: 'How do I reset my password?',
isOutgoing: false,
slideFrom: 'right',
},
);
const emit = defineEmits<{ complete: [] }>();
const visible = ref(false);
const bubbleVisible = ref(false);
let timers: Array<ReturnType<typeof setTimeout>> = [];
function clearTimers() {
for (const t of timers) clearTimeout(t);
timers = [];
}
function runAnimation() {
visible.value = false;
bubbleVisible.value = false;
timers.push(
setTimeout(() => {
visible.value = true;
}, APPEAR_DELAY_MS),
);
timers.push(
setTimeout(() => {
bubbleVisible.value = true;
}, APPEAR_DELAY_MS + BUBBLE_DELAY_MS),
);
timers.push(
setTimeout(() => {
emit('complete');
}, APPEAR_DELAY_MS + COMPLETE_DELAY_MS),
);
}
watch(
() => props.active,
(val) => {
if (val) {
runAnimation();
} else {
clearTimers();
visible.value = false;
bubbleVisible.value = false;
}
},
{ immediate: true },
);
onUnmounted(clearTimers);
</script>
<template>
<div
:class="[$style.card, visible && $style.cardVisible, slideFrom === 'left' && $style.slideLeft]"
>
<div :class="$style.header">
<div :class="$style.avatar">
<img :src="WHATSAPP_ICON" :class="$style.avatarIcon" alt="" />
</div>
<span :class="$style.sender">{{ sender }}</span>
</div>
<div v-if="bubbleVisible" :class="[$style.bubbleRow, isOutgoing && $style.bubbleRowOutgoing]">
<div :class="[$style.bubble, isOutgoing ? $style.bubbleOutgoing : $style.bubbleIncoming]">
<p :class="$style.bubbleText">{{ message }}</p>
<svg
v-if="isOutgoing"
:class="$style.readTicks"
width="18"
height="12"
viewBox="0 0 18 12"
fill="none"
>
<path
d="M1 6 L4.5 9.5 L10 2"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5 6 L8.5 9.5 L14 2"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
</div>
</template>
<style lang="scss" module>
.card {
width: 280px;
padding: var(--spacing--sm) var(--spacing--md);
background: var(--color--background--light-3);
border-radius: var(--radius--lg);
border: 1px solid
light-dark(
oklch(from var(--color--neutral-black) l c h / 0.08),
oklch(from var(--color--neutral-white) l c h / 0.12)
);
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
opacity: 0;
transform: translateX(8px);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.slideLeft {
transform: translateX(-8px);
}
.cardVisible {
opacity: 1;
transform: translateX(0);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: #25d366;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
}
.avatarIcon {
width: 100%;
height: 100%;
}
.sender {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
color: var(--color--text--base);
}
.bubbleRow {
display: flex;
justify-content: flex-start;
}
.bubbleRowOutgoing {
justify-content: flex-end;
}
.bubble {
max-width: 85%;
border-radius: 8px;
padding: 6px 10px;
animation: bubble-pop 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.bubbleIncoming {
background: light-dark(#ebebeb, #3a3a3a);
border-bottom-left-radius: 2px;
}
.bubbleOutgoing {
background: light-dark(#d9fdd3, #1a4a2a);
border-bottom-right-radius: 2px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.bubbleText {
margin: 0;
font-size: var(--font-size--xs);
color: var(--color--text--base);
line-height: 1.4;
}
.readTicks {
color: #8696a0;
flex-shrink: 0;
}
@keyframes bubble-pop {
from {
opacity: 0;
transform: scale(0.85);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>

View File

@ -0,0 +1,9 @@
export { useInstanceAiWorkflowPreviewSuggestionsExperiment } from './useInstanceAiWorkflowPreviewSuggestionsExperiment';
export {
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS,
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION,
type WorkflowPreviewSuggestion,
} from './suggestions';
export { default as WorkflowPreviewSuggestions } from './components/WorkflowPreviewSuggestions.vue';
export { default as WorkflowPreviewCanvas } from './components/WorkflowPreviewCanvas.vue';
export { getPreviewWorkflow } from './workflows';

View File

@ -0,0 +1,52 @@
import type { IconName } from '@n8n/design-system';
import type { BaseTextKey } from '@n8n/i18n';
import type { InstanceAiEmptyStatePromptSuggestion } from '@/features/ai/instanceAi/emptyStateSuggestions';
export interface WorkflowPreviewSuggestion extends InstanceAiEmptyStatePromptSuggestion {
workflowFile: string;
}
export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION = 'v3-workflow-preview';
export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS: readonly WorkflowPreviewSuggestion[] = [
{
type: 'prompt',
id: 'score-my-leads',
icon: 'badge-check' as IconName,
labelKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.label' as BaseTextKey,
promptKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.prompt' as BaseTextKey,
workflowFile: 'score-my-leads',
},
{
type: 'prompt',
id: 'process-invoices',
icon: 'file-text' as IconName,
labelKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.label' as BaseTextKey,
promptKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.prompt' as BaseTextKey,
workflowFile: 'process-invoices',
},
{
type: 'prompt',
id: 'whatsapp-support',
icon: 'message-circle' as IconName,
labelKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.label' as BaseTextKey,
promptKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.prompt' as BaseTextKey,
workflowFile: 'whatsapp-support',
},
{
type: 'prompt',
id: 'schedule-social-posts',
icon: 'calendar' as IconName,
labelKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.label' as BaseTextKey,
promptKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.prompt' as BaseTextKey,
workflowFile: 'schedule-social-posts',
},
];

View File

@ -0,0 +1,16 @@
import { computed } from 'vue';
import { INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT } from '@/app/constants/experiments';
import { usePostHog } from '@/app/stores/posthog.store';
export function useInstanceAiWorkflowPreviewSuggestionsExperiment() {
const posthogStore = usePostHog();
const isFeatureEnabled = computed(
() =>
posthogStore.getVariant(INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT.name) ===
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT.variant,
);
return { isFeatureEnabled };
}

View File

@ -0,0 +1,27 @@
import type { PreviewWorkflow } from './types';
import { scoreMyLeadsWorkflow } from './score-my-leads';
import { processInvoicesWorkflow } from './process-invoices';
import { whatsappSupportWorkflow } from './whatsapp-support';
import { scheduleSocialPostsWorkflow } from './schedule-social-posts';
export type {
PreviewWorkflow,
PreviewWorkflowNode,
PreviewWorkflowConnection,
PreviewVisualization,
PreviewVisualizationType,
PreviewOutputVisualization,
CrmCycleConfig,
CrmCycleVariant,
} from './types';
const workflowRegistry: Record<string, PreviewWorkflow> = {
'score-my-leads': scoreMyLeadsWorkflow,
'process-invoices': processInvoicesWorkflow,
'whatsapp-support': whatsappSupportWorkflow,
'schedule-social-posts': scheduleSocialPostsWorkflow,
};
export function getPreviewWorkflow(workflowFile: string): PreviewWorkflow | undefined {
return workflowRegistry[workflowFile];
}

View File

@ -0,0 +1,79 @@
import type { PreviewWorkflow } from './types';
const GMAIL_ICON_SVG =
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%22256%22%20height=%22193%22%20preserveAspectRatio=%22xMidYMid%22%3E%3Cpath%20fill=%22%234285F4%22%20d=%22M58.182%20192.05V93.14L27.507%2065.077%200%2049.504v125.091c0%209.658%207.825%2017.455%2017.455%2017.455z%22/%3E%3Cpath%20fill=%22%2334A853%22%20d=%22M197.818%20192.05h40.727c9.659%200%2017.455-7.826%2017.455-17.455V49.505l-31.156%2017.837-27.026%2025.798z%22/%3E%3Cpath%20fill=%22%23EA4335%22%20d=%22m58.182%2093.14-4.174-38.647%204.174-36.989L128%2069.868l69.818-52.364%204.67%2034.992-4.67%2040.644L128%20145.504z%22/%3E%3Cpath%20fill=%22%23FBBC04%22%20d=%22M197.818%2017.504V93.14L256%2049.504V26.231c0-21.585-24.64-33.89-41.89-20.945z%22/%3E%3Cpath%20fill=%22%23C5221F%22%20d=%22m0%2049.504%2026.759%2020.07L58.182%2093.14V17.504L41.89%205.286C24.61-7.66%200%204.646%200%2026.23z%22/%3E%3C/svg%3E';
const ANTHROPIC_ICON_SVG =
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%2246%22%20height=%2232%22%20fill=%22none%22%3E%3Cpath%20fill=%22%237D7D87%22%20d=%22M32.73%200h-6.945L38.45%2032h6.945zM12.665%200%200%2032h7.082l2.59-6.72h13.25l2.59%206.72h7.082L19.929%200zm-.702%2019.337%204.334-11.246%204.334%2011.246z%22/%3E%3C/svg%3E';
const GOOGLE_SHEETS_ICON_SVG =
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%2260%22%20height=%2260%22%3E%3Cg%20fill=%22none%22%20fill-rule=%22evenodd%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22%3E%3Cpath%20fill=%22%2328B446%22%20d=%22M35.69%201%2052%2017.225v39.087a3.67%203.67%200%200%201-1.084%202.61A3.7%203.7%200%200%201%2048.293%2060H12.707a3.7%203.7%200%200%201-2.623-1.078A3.67%203.67%200%200%201%209%2056.312V4.688a3.67%203.67%200%200%201%201.084-2.61A3.7%203.7%200%200%201%2012.707%201z%22/%3E%3Cpath%20fill=%22%236ACE7C%22%20d=%22M35.69%201%2052%2017.225H39.397c-2.054%200-3.707-1.829-3.707-3.872z%22/%3E%3Cpath%20fill=%22%23219B38%22%20d=%22M39.211%2017.225%2052%2022.48v-5.255z%22/%3E%3Cpath%20fill=%22%23FFF%22%20d=%22M20.12%2031.975c0-.817.662-1.475%201.483-1.475h17.794c.821%200%201.482.658%201.482%201.475v15.487c0%20.818-.661%201.475-1.482%201.475H21.603a1.476%201.476%200%200%201-1.482-1.474V31.974zm2.225%201.475h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672zm9.638-10.325h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672z%22/%3E%3Cpath%20fill=%22%2328B446%22%20d=%22M34.69%200%2051%2016.225v39.087a3.67%203.67%200%200%201-1.084%202.61A3.7%203.7%200%200%201%2047.293%2059H11.707a3.7%203.7%200%200%201-2.623-1.078A3.67%203.67%200%200%201%208%2055.312V3.688a3.67%203.67%200%200%201%201.084-2.61A3.7%203.7%200%200%201%2011.707%200z%22/%3E%3Cpath%20fill=%22%236ACE7C%22%20d=%22M34.69%200%2051%2016.225H38.397c-2.054%200-3.707-1.829-3.707-3.872z%22/%3E%3Cpath%20fill=%22%23219B38%22%20d=%22M38.211%2016.225%2051%2021.48v-5.255z%22/%3E%3Cpath%20fill=%22%23FFF%22%20d=%22M19.12%2030.975c0-.817.662-1.475%201.483-1.475h17.794c.821%200%201.482.658%201.482%201.475v15.487c0%20.818-.661%201.475-1.482%201.475H20.603a1.476%201.476%200%200%201-1.482-1.474V30.974zm2.225%201.475h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672zm9.638-10.325h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672z%22/%3E%3C/g%3E%3C/svg%3E';
const GOOGLE_CALENDAR_ICON_SVG =
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20xmlns:xlink=%22http://www.w3.org/1999/xlink%22%20fill=%22%23fff%22%20fill-rule=%22evenodd%22%20stroke=%22%23000%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22%20viewBox=%220%200%2081%2082%22%3E%3Cuse%20xlink:href=%22%23a%22%20x=%22.5%22%20y=%22.5%22/%3E%3Csymbol%20id=%22a%22%20overflow=%22visible%22%3E%3Cg%20fill-rule=%22nonzero%22%20stroke=%22none%22%3E%3Cpath%20d=%22M61.052%2018.947H18.947v42.105h42.105z%22/%3E%3Cpath%20fill=%22%23ea4335%22%20d=%22M61.053%2080%2080%2061.053H61.053z%22/%3E%3Cpath%20fill=%22%23fbbc04%22%20d=%22M80%2018.947H61.053v42.105H80z%22/%3E%3Cpath%20fill=%22%2334a853%22%20d=%22M61.052%2061.053H18.947V80h42.105z%22/%3E%3Cpath%20fill=%22%23188038%22%20d=%22M0%2061.053v12.632A6.314%206.314%200%200%200%206.316%2080h12.632V61.053z%22/%3E%3Cpath%20fill=%22%231967d2%22%20d=%22M80%2018.947V6.316A6.314%206.314%200%200%200%2073.685%200H61.053v18.947z%22/%3E%3Cpath%20fill=%22%234285f4%22%20d=%22M61.053%200H6.316A6.314%206.314%200%200%200%200%206.316v54.737h18.947V18.947h42.105V0zM27.584%2051.611c-1.574-1.063-2.663-2.616-3.258-4.668l3.653-1.505q.498%201.894%201.737%202.937c1.239%201.043%201.821%201.037%202.989%201.037q1.792%200%203.079-1.089c1.287-1.089%201.29-1.653%201.29-2.774a3.44%203.44%200%200%200-1.358-2.811c-.905-.727-2.042-1.089-3.4-1.089h-2.111v-3.616H32.1q1.752%200%202.953-.947c1.201-.947%201.2-1.495%201.2-2.595q0-1.467-1.074-2.342c-1.074-.875-1.621-.879-2.721-.879q-1.61-.002-2.558.858c-.948.86-1.106%201.301-1.379%202.111l-3.616-1.505c.479-1.358%201.358-2.558%202.647-3.595s2.937-1.558%204.937-1.558q2.22-.002%203.989.858c1.769.86%202.105%201.368%202.774%202.379s1%202.153%201%203.416q0%201.932-.932%203.274c-.932%201.342-1.384%201.579-2.289%202.058v.216a6.95%206.95%200%200%201%202.937%202.289q1.146%201.538%201.147%203.684c.001%202.146-.363%202.711-1.089%203.832s-1.732%202.005-3.005%202.647c-1.279.642-2.716.968-4.311.968-1.847.005-3.553-.526-5.126-1.589zm22.437-18.126-4.01%202.9-2.005-3.042%207.195-5.189h2.758v24.479h-3.937V33.484z%22/%3E%3C/g%3E%3C/symbol%3E%3C/svg%3E';
export const processInvoicesWorkflow: PreviewWorkflow = {
nodes: [
{
id: 'gmail-trigger',
label: 'Invoice received',
icon: { type: 'file', src: GMAIL_ICON_SVG },
position: { x: 0, y: 120 },
},
{
id: 'claude',
label: 'Extract & cross-check',
icon: { type: 'file', src: ANTHROPIC_ICON_SVG },
position: { x: 240, y: 120 },
},
{
id: 'if-discrepancy',
label: 'Discrepancy?',
icon: { type: 'icon', name: 'node:if' },
iconColor: 'green',
position: { x: 480, y: 120 },
},
{
id: 'flag-invoice',
label: 'Flag invoice',
icon: { type: 'file', src: GOOGLE_SHEETS_ICON_SVG },
position: { x: 720, y: 0 },
},
{
id: 'add-calendar',
label: 'Add to Calendar',
icon: { type: 'file', src: GOOGLE_CALENDAR_ICON_SVG },
position: { x: 720, y: 240 },
},
],
connections: [
{ source: 'gmail-trigger', target: 'claude' },
{ source: 'claude', target: 'if-discrepancy' },
{ source: 'if-discrepancy', target: 'flag-invoice' },
{ source: 'if-discrepancy', target: 'add-calendar' },
],
inputVisualization: {
type: 'salesforce-card',
props: {
icon: GMAIL_ICON_SVG,
title: 'Invoice received',
subtitle: 'Subject: INV-2024-047',
},
},
outputVisualization: [
{
type: 'invoice-spreadsheet',
targetNodeId: 'flag-invoice',
props: {},
},
{
type: 'salesforce-card',
targetNodeId: 'add-calendar',
props: {
icon: GOOGLE_CALENDAR_ICON_SVG,
title: 'Event created',
subtitle: 'Payment due · Jun 15',
},
},
],
};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,54 @@
export interface PreviewWorkflowNodeIcon {
type: 'icon' | 'file';
name?: string;
src?: string;
}
export interface PreviewWorkflowNode {
id: string;
label: string;
icon: PreviewWorkflowNodeIcon;
iconColor?: string;
position: { x: number; y: number };
}
export interface PreviewWorkflowConnection {
source: string;
target: string;
}
export type PreviewVisualizationType =
| 'slack-message'
| 'salesforce-card'
| 'invoice-spreadsheet'
| 'whatsapp-chat';
export interface PreviewVisualization {
type: PreviewVisualizationType;
props?: Record<string, unknown>;
}
export interface PreviewOutputVisualization {
type: PreviewVisualizationType;
props?: Record<string, unknown>;
targetNodeId: string;
}
export interface CrmCycleVariant {
icon: PreviewWorkflowNodeIcon;
label: string;
}
export interface CrmCycleConfig {
nodeIds: string[];
variants: CrmCycleVariant[];
intervalMs?: number;
}
export interface PreviewWorkflow {
nodes: PreviewWorkflowNode[];
connections: PreviewWorkflowConnection[];
inputVisualization?: PreviewVisualization;
outputVisualization?: PreviewVisualization | PreviewOutputVisualization[];
crmCycle?: CrmCycleConfig;
}

View File

@ -22,6 +22,14 @@ import {
INSTANCE_AI_PROMPT_SUGGESTIONS_V2_VERSION, INSTANCE_AI_PROMPT_SUGGESTIONS_V2_VERSION,
useInstanceAiPromptSuggestionsV2Experiment, useInstanceAiPromptSuggestionsV2Experiment,
} from '@/experiments/instanceAiPromptSuggestionsV2'; } from '@/experiments/instanceAiPromptSuggestionsV2';
import {
WorkflowPreviewSuggestions,
WorkflowPreviewCanvas,
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS,
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION,
getPreviewWorkflow,
useInstanceAiWorkflowPreviewSuggestionsExperiment,
} from '@/experiments/instanceAiWorkflowPreviewSuggestions';
import InstanceAiInput from './components/InstanceAiInput.vue'; import InstanceAiInput from './components/InstanceAiInput.vue';
import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue'; import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue';
import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue'; import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue';
@ -33,6 +41,10 @@ const INSTANCE_AI_PROMPT_SUGGESTIONS_V2_TITLE_KEY: BaseTextKey =
'experiments.instanceAiPromptSuggestionsV2.emptyState.title'; 'experiments.instanceAiPromptSuggestionsV2.emptyState.title';
const INSTANCE_AI_PROMPT_SUGGESTIONS_V2_PLACEHOLDER_KEY: BaseTextKey = const INSTANCE_AI_PROMPT_SUGGESTIONS_V2_PLACEHOLDER_KEY: BaseTextKey =
'experiments.instanceAiPromptSuggestionsV2.input.placeholder'; 'experiments.instanceAiPromptSuggestionsV2.input.placeholder';
const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_TITLE_KEY =
'experiments.instanceAiWorkflowPreviewSuggestions.emptyState.title' as BaseTextKey;
const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_PLACEHOLDER_KEY =
'experiments.instanceAiWorkflowPreviewSuggestions.input.placeholder' as BaseTextKey;
const store = useInstanceAiStore(); const store = useInstanceAiStore();
const { isLowCredits } = storeToRefs(store); const { isLowCredits } = storeToRefs(store);
@ -45,7 +57,15 @@ const { isFeatureEnabled: isProactiveAgentExperimentEnabled } =
useInstanceAiProactiveAgentExperiment(); useInstanceAiProactiveAgentExperiment();
const { isFeatureEnabled: isPromptSuggestionsV2ExperimentEnabled } = const { isFeatureEnabled: isPromptSuggestionsV2ExperimentEnabled } =
useInstanceAiPromptSuggestionsV2Experiment(); useInstanceAiPromptSuggestionsV2Experiment();
const { isFeatureEnabled: isWorkflowPreviewSuggestionsExperimentEnabled } =
useInstanceAiWorkflowPreviewSuggestionsExperiment();
const showProactiveStarter = computed(() => isProactiveAgentExperimentEnabled.value); const showProactiveStarter = computed(() => isProactiveAgentExperimentEnabled.value);
const activeWorkflowPreviewFile = ref<string | null>(null);
const activeWorkflowPreview = computed(() => {
if (!activeWorkflowPreviewFile.value) return null;
return getPreviewWorkflow(activeWorkflowPreviewFile.value) ?? null;
});
// Experiment cleanup: remove with instanceAiPromptSuggestionsV2. // Experiment cleanup: remove with instanceAiPromptSuggestionsV2.
const emptyStatePromptSuggestionProps = computed(() => { const emptyStatePromptSuggestionProps = computed(() => {
if (showProactiveStarter.value) { if (showProactiveStarter.value) {
@ -61,19 +81,35 @@ const emptyStatePromptSuggestionProps = computed(() => {
}; };
} }
if (isWorkflowPreviewSuggestionsExperimentEnabled.value) {
return {
suggestions: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS,
suggestionsComponent: WorkflowPreviewSuggestions,
suggestionCatalogVersion: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION,
placeholderKey: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_PLACEHOLDER_KEY,
};
}
return { return {
suggestions: INSTANCE_AI_EMPTY_STATE_SUGGESTIONS, suggestions: INSTANCE_AI_EMPTY_STATE_SUGGESTIONS,
}; };
}); });
const emptyStateTitleKey = computed<BaseTextKey>(() => const emptyStateTitleKey = computed<BaseTextKey>(() => {
isPromptSuggestionsV2ExperimentEnabled.value if (isPromptSuggestionsV2ExperimentEnabled.value) {
? INSTANCE_AI_PROMPT_SUGGESTIONS_V2_TITLE_KEY return INSTANCE_AI_PROMPT_SUGGESTIONS_V2_TITLE_KEY;
: INSTANCE_AI_DEFAULT_TITLE_KEY, }
); if (isWorkflowPreviewSuggestionsExperimentEnabled.value) {
return INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_TITLE_KEY;
}
return INSTANCE_AI_DEFAULT_TITLE_KEY;
});
const chatInputRef = ref<InstanceType<typeof InstanceAiInput> | null>(null); const chatInputRef = ref<InstanceType<typeof InstanceAiInput> | null>(null);
const isStartingThread = ref(false); const isStartingThread = ref(false);
function handleWorkflowPreview(workflowFile: string | null) {
activeWorkflowPreviewFile.value = workflowFile;
}
onMounted(() => { onMounted(() => {
void nextTick(() => chatInputRef.value?.focus()); void nextTick(() => chatInputRef.value?.focus());
}); });
@ -141,8 +177,16 @@ async function handleSubmit(message: string, attachments?: InstanceAiAttachment[
:is-submitting="isStartingThread" :is-submitting="isStartingThread"
v-bind="emptyStatePromptSuggestionProps" v-bind="emptyStatePromptSuggestionProps"
@submit="handleSubmit" @submit="handleSubmit"
@workflow-preview="handleWorkflowPreview"
/> />
</div> </div>
<Transition name="workflow-preview-fade">
<WorkflowPreviewCanvas
v-if="isWorkflowPreviewSuggestionsExperimentEnabled && activeWorkflowPreview"
:workflow="activeWorkflowPreview"
:class="$style.workflowPreview"
/>
</Transition>
</div> </div>
</div> </div>
</div> </div>
@ -181,6 +225,11 @@ async function handleSubmit(message: string, attachments?: InstanceAiAttachment[
max-width: 680px; max-width: 680px;
} }
.workflowPreview {
width: 100%;
max-width: 1600px;
}
.proactiveLayout { .proactiveLayout {
flex: 1; flex: 1;
display: flex; display: flex;
@ -205,4 +254,26 @@ async function handleSubmit(message: string, attachments?: InstanceAiAttachment[
margin: 0 auto; margin: 0 auto;
padding: 0 var(--spacing--lg) var(--spacing--sm); padding: 0 var(--spacing--lg) var(--spacing--sm);
} }
:global(.workflow-preview-fade-enter-active) {
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
:global(.workflow-preview-fade-leave-active) {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
:global(.workflow-preview-fade-enter-from) {
opacity: 0;
transform: translateY(8px);
}
:global(.workflow-preview-fade-leave-to) {
opacity: 0;
transform: translateY(4px);
}
</style> </style>

View File

@ -15,12 +15,15 @@ const {
experimentMocks, experimentMocks,
promptSuggestionsV2, promptSuggestionsV2,
promptSuggestionsV2Component, promptSuggestionsV2Component,
workflowPreviewSuggestions,
workflowPreviewSuggestionsComponent,
replaceMock, replaceMock,
showErrorMock, showErrorMock,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
experimentMocks: { experimentMocks: {
proactiveAgentEnabled: { value: false }, proactiveAgentEnabled: { value: false },
promptSuggestionsV2Enabled: { value: false }, promptSuggestionsV2Enabled: { value: false },
workflowPreviewEnabled: { value: false },
}, },
promptSuggestionsV2: Array.from({ length: 12 }, (_, index) => ({ promptSuggestionsV2: Array.from({ length: 12 }, (_, index) => ({
type: 'prompt', type: 'prompt',
@ -30,6 +33,14 @@ const {
promptKey: 'instanceAi.emptyState.suggestions.buildAgent.prompt', promptKey: 'instanceAi.emptyState.suggestions.buildAgent.prompt',
})), })),
promptSuggestionsV2Component: { name: 'InstanceAiPromptSuggestionsV2Stub' }, promptSuggestionsV2Component: { name: 'InstanceAiPromptSuggestionsV2Stub' },
workflowPreviewSuggestions: Array.from({ length: 4 }, (_, index) => ({
type: 'prompt',
id: `wp-suggestion-${index + 1}`,
icon: 'workflow',
labelKey: 'instanceAi.emptyState.suggestions.buildWorkflow.label',
promptKey: 'instanceAi.emptyState.suggestions.buildWorkflow.prompt',
})),
workflowPreviewSuggestionsComponent: { name: 'WorkflowPreviewSuggestionsStub' },
replaceMock: vi.fn(), replaceMock: vi.fn(),
showErrorMock: vi.fn(), showErrorMock: vi.fn(),
})); }));
@ -53,6 +64,17 @@ vi.mock('@/experiments/instanceAiPromptSuggestionsV2', () => ({
InstanceAiPromptSuggestionsV2: promptSuggestionsV2Component, InstanceAiPromptSuggestionsV2: promptSuggestionsV2Component,
})); }));
vi.mock('@/experiments/instanceAiWorkflowPreviewSuggestions', () => ({
useInstanceAiWorkflowPreviewSuggestionsExperiment: () => ({
isFeatureEnabled: experimentMocks.workflowPreviewEnabled,
}),
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS: workflowPreviewSuggestions,
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION: 'v3-workflow-preview',
WorkflowPreviewSuggestions: workflowPreviewSuggestionsComponent,
WorkflowPreviewCanvas: { name: 'WorkflowPreviewCanvasStub', template: '<div />' },
getPreviewWorkflow: () => null,
}));
vi.mock('@/app/composables/usePageRedirectionHelper', () => ({ vi.mock('@/app/composables/usePageRedirectionHelper', () => ({
usePageRedirectionHelper: () => ({ goToUpgrade: vi.fn() }), usePageRedirectionHelper: () => ({ goToUpgrade: vi.fn() }),
})); }));
@ -160,6 +182,7 @@ describe('InstanceAiEmptyView', () => {
store.getOrCreateRuntime.mockReturnValue(thread); store.getOrCreateRuntime.mockReturnValue(thread);
experimentMocks.proactiveAgentEnabled.value = false; experimentMocks.proactiveAgentEnabled.value = false;
experimentMocks.promptSuggestionsV2Enabled.value = false; experimentMocks.promptSuggestionsV2Enabled.value = false;
experimentMocks.workflowPreviewEnabled.value = false;
}); });
afterEach(() => { afterEach(() => {
@ -192,6 +215,22 @@ describe('InstanceAiEmptyView', () => {
); );
}); });
it('passes workflow preview suggestions, component, and catalog version when workflow preview experiment is enabled', () => {
experimentMocks.workflowPreviewEnabled.value = true;
const { getByTestId, getByText } = renderView();
expect(getByText('What do you want to automate?')).toBeVisible();
expect(getByTestId('instance-ai-input-suggestions')).toHaveTextContent('4');
expect(getByTestId('instance-ai-input-suggestions-component')).toHaveTextContent('set');
expect(getByTestId('instance-ai-input-suggestion-catalog-version')).toHaveTextContent(
'v3-workflow-preview',
);
expect(getByTestId('instance-ai-input-placeholder-key')).toHaveTextContent(
'experiments.instanceAiWorkflowPreviewSuggestions.input.placeholder',
);
});
it('renders the proactive starter and moves suggestions out of the composer when enabled', () => { it('renders the proactive starter and moves suggestions out of the composer when enabled', () => {
experimentMocks.proactiveAgentEnabled.value = true; experimentMocks.proactiveAgentEnabled.value = true;
experimentMocks.promptSuggestionsV2Enabled.value = true; experimentMocks.promptSuggestionsV2Enabled.value = true;

View File

@ -61,6 +61,7 @@ const emit = defineEmits<{
submit: [message: string, attachments?: InstanceAiAttachment[]]; submit: [message: string, attachments?: InstanceAiAttachment[]];
stop: []; stop: [];
'cancel-plan-edit': []; 'cancel-plan-edit': [];
'workflow-preview': [workflowFile: string | null];
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
@ -135,6 +136,7 @@ watch(
} }
previewPromptKey.value = null; previewPromptKey.value = null;
emit('workflow-preview', null);
}, },
{ immediate: true }, { immediate: true },
); );
@ -372,6 +374,7 @@ const resizable = computed(() => {
@cycle-suggestions="handleSuggestionsCycled" @cycle-suggestions="handleSuggestionsCycled"
@insert-suggestion="handleSuggestionInsert" @insert-suggestion="handleSuggestionInsert"
@submit-suggestion="handleSuggestionSubmit" @submit-suggestion="handleSuggestionSubmit"
@workflow-preview="emit('workflow-preview', $event)"
/> />
</Transition> </Transition>
</div> </div>