refactor(editor): Streamline fix-with-AI offer state and reporting (#30827)

This commit is contained in:
Raúl Gómez Morales 2026-05-21 09:49:03 +02:00 committed by GitHub
parent bd3aafce75
commit bfb50dc630
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 214 additions and 249 deletions

View File

@ -1,81 +1,62 @@
import { computed, watch } from 'vue';
import { onBeforeUnmount } from 'vue';
import { useRoute } from 'vue-router';
import { VIEWS } from '@/app/constants';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import type { FixWithAiError } from '@/features/ai/instanceAi/useFixWithAiOffer';
import type { IRunData } from 'n8n-workflow';
import { VIEWS } from '@/app/constants';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import type { FixWithAiError } from '@/features/ai/instanceAi/fixWithAi';
function collectErrorsFromRunData(runData: IRunData): FixWithAiError[] {
const errors: FixWithAiError[] = [];
for (const [nodeName, tasks] of Object.entries(runData)) {
const lastTask = tasks?.at(-1);
const error = lastTask?.error;
const error = tasks?.at(-1)?.error;
if (!error) continue;
const description = error.description ? ` (${error.description})` : '';
errors.push({
nodeName,
errorMessage: `${error.message ?? 'Unknown error'}${description}`,
});
}
return errors;
}
export function useReportWorkflowFailuresToParent(workflowName?: () => string | undefined) {
/**
* Lives in the embedded canvas iframe. Listens for `executionFinished` push
* events on the iframe's own connection (parent has a separate `pushRef`, so
* iframe-triggered runs are not visible there) and forwards per-node failures
* to the parent via `postMessage` so the chat can surface a "Fix with AI" card.
*
* Reads the per-execution data store (keyed by the executionId we just got the
* finish event for) once per failed run by then the in-iframe push handlers
* have already populated it. Avoids a deep `watch` over the whole run-data tree.
*/
export function useReportWorkflowFailuresToParent() {
if (window.parent === window) return;
const route = useRoute();
const workflowsStore = useWorkflowsStore();
const isEnabled = computed(() => route.name === VIEWS.DEMO && route.query.canExecute === 'true');
let lastReportedKey: string | null = null;
const pushStore = usePushConnectionStore();
watch(
[
isEnabled,
() => workflowsStore.getWorkflowRunData,
() => workflowsStore.isWorkflowRunning,
() => workflowsStore.workflowId,
() => workflowsStore.getWorkflowExecution?.id,
],
() => {
if (!isEnabled.value || workflowsStore.isWorkflowRunning) return;
const removeListener = pushStore.addEventListener((event) => {
if (route.name !== VIEWS.DEMO || route.query.canExecute !== 'true') return;
if (event.type !== 'executionFinished') return;
if (event.data.status === 'success') return;
const workflowId = workflowsStore.workflowId;
const runData = workflowsStore.getWorkflowRunData;
const execStore = useExecutionDataStore(createExecutionDataId(event.data.executionId));
const runData = execStore.executionRunData;
if (!runData) return;
const errors = collectErrorsFromRunData(runData);
if (errors.length === 0) return;
if (!workflowId || !runData) return;
window.parent.postMessage(
JSON.stringify({
command: 'reportWorkflowFailures',
workflowId: event.data.workflowId,
executionId: event.data.executionId,
errors,
}),
window.location.origin,
);
});
const errors = collectErrorsFromRunData(runData);
if (errors.length === 0) return;
const executionId = workflowsStore.getWorkflowExecution?.id;
const fingerprint = errors
.map((e) => `${e.nodeName}:${e.errorMessage}`)
.sort()
.join('|');
const dismissKey = executionId ?? fingerprint;
if (!dismissKey || lastReportedKey === dismissKey) return;
lastReportedKey = dismissKey;
window.parent.postMessage(
JSON.stringify({
command: 'reportWorkflowFailures',
workflowId,
workflowName: workflowName?.(),
executionId: dismissKey,
errors,
}),
window.location.origin,
);
},
{ deep: true },
);
onBeforeUnmount(removeListener);
}

View File

@ -43,7 +43,7 @@ const { setup: setupPostMessages, cleanup: cleanupPostMessages } = usePostMessag
currentNDVStore,
});
useReportWorkflowFailuresToParent(() => currentWorkflowDocumentStore.value?.name ?? undefined);
useReportWorkflowFailuresToParent();
// Initialize push event handlers so relayed execution events (via postMessage
// from the parent) are processed for node highlighting, execution state, etc.

View File

@ -50,7 +50,7 @@ import CreditWarningBanner from '@/features/ai/assistant/components/Agent/Credit
import InstanceAiWorkflowPreview, {
type WorkflowFailuresReport,
} from './components/InstanceAiWorkflowPreview.vue';
import { buildFixWithAiPrompt, useFixWithAiOffer } from './useFixWithAiOffer';
import { buildFixWithAiPrompt } from './fixWithAi';
import InstanceAiDataTablePreview from './components/InstanceAiDataTablePreview.vue';
import { TabsRoot } from 'reka-ui';
@ -87,40 +87,26 @@ const hasFloatingConfirmation = computed(() =>
thread.pendingConfirmations.some(isPendingItemFloating),
);
// --- Execution tracking via push events ---
// --- Execution tracking via push events (drives canvas relay) ---
const executionTracking = useExecutionPushEvents();
const fixWithAiOffer = useFixWithAiOffer();
// --- Fix-with-AI offer (failure data sent by the iframe via postMessage) ---
const failedRun = ref<WorkflowFailuresReport | null>(null);
const dismissedExecutionId = ref<string | null>(null);
const isChatInProgress = computed(
() => thread.isStreaming || thread.isSendingMessage || thread.isAwaitingConfirmation,
);
function findReadyFixWithAiOffer(workflowId?: string | null) {
const offers = fixWithAiOffer.offersByWorkflow.value;
if (workflowId) {
const preferred = offers.get(workflowId);
if (
preferred &&
preferred.errors.length > 0 &&
!fixWithAiOffer.isDismissed(preferred.executionId)
) {
return preferred;
}
}
for (const offer of offers.values()) {
if (offer.errors.length === 0) continue;
if (fixWithAiOffer.isDismissed(offer.executionId)) continue;
return offer;
}
return null;
}
const activeFixWithAiOffer = computed(() => {
const run = failedRun.value;
if (!run) return null;
if (run.executionId === dismissedExecutionId.value) return null;
if (isChatInProgress.value) return null;
return findReadyFixWithAiOffer(preview.activeWorkflowId.value);
return {
...run,
workflowName: thread.producedArtifacts.get(run.workflowId)?.name,
};
});
// --- Header title ---
@ -499,7 +485,6 @@ onUnmounted(() => {
contentResizeObserver?.disconnect();
resizeObserver?.disconnect();
executionTracking.cleanup();
fixWithAiOffer.cleanup();
});
// --- Workflow preview ref for iframe relay ---
@ -529,7 +514,7 @@ function handleFixWithAiFromOffer() {
const offer = activeFixWithAiOffer.value;
if (!offer) return;
fixWithAiOffer.dismiss(offer.executionId);
dismissedExecutionId.value = offer.executionId;
userScrolledUp.value = false;
void thread.sendMessage(
buildFixWithAiPrompt({ workflowName: offer.workflowName, errors: offer.errors }),
@ -541,11 +526,11 @@ function handleFixWithAiFromOffer() {
function dismissFixWithAiOffer() {
const offer = activeFixWithAiOffer.value;
if (!offer) return;
fixWithAiOffer.dismiss(offer.executionId);
dismissedExecutionId.value = offer.executionId;
}
function handleWorkflowFailures(report: WorkflowFailuresReport) {
fixWithAiOffer.registerOffer(report);
failedRun.value = report;
}
</script>

View File

@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, ref } from 'vue';
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
@ -8,6 +9,7 @@ import InstanceAiThreadView from '../InstanceAiThreadView.vue';
import { useInstanceAiStore, type ThreadRuntime } from '../instanceAi.store';
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { SidebarStateKey } from '../instanceAiLayout';
import type { WorkflowFailuresReport } from '../components/InstanceAiWorkflowPreview.vue';
const mockWindowSizeState = vi.hoisted(() => ({
width: { value: 1200 },
@ -54,6 +56,20 @@ const InstanceAiInputStub = defineComponent({
},
});
let workflowPreviewEmit:
| ((event: 'workflow-failures', payload: WorkflowFailuresReport) => void)
| null = null;
const InstanceAiWorkflowPreviewStub = defineComponent({
name: 'InstanceAiWorkflowPreviewStub',
emits: ['iframe-ready', 'workflow-loaded', 'workflow-failures'],
setup(_, { emit, expose }) {
workflowPreviewEmit = emit as typeof workflowPreviewEmit;
expose({ relayPushEvent: vi.fn(), requestFitView: vi.fn() });
return () => h('div', { 'data-test-id': 'instance-ai-workflow-preview-stub' });
},
});
const InstanceAiConfirmationPanelStub = defineComponent({
name: 'InstanceAiConfirmationPanelStub',
props: {
@ -71,6 +87,7 @@ const renderView = createComponentRenderer(InstanceAiThreadView, {
},
stubs: {
InstanceAiInput: InstanceAiInputStub,
InstanceAiWorkflowPreview: InstanceAiWorkflowPreviewStub,
InstanceAiConfirmationPanel: InstanceAiConfirmationPanelStub,
},
},
@ -85,6 +102,8 @@ describe('InstanceAiThreadView', () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
workflowPreviewEmit = null;
thread = {
id: 'thread-1',
messages: [],
@ -263,4 +282,75 @@ describe('InstanceAiThreadView', () => {
});
expect(queryByTestId('instance-ai-artifacts-sidebar-slot')).not.toBeInTheDocument();
});
describe('Fix with AI card', () => {
const failureReport: WorkflowFailuresReport = {
workflowId: 'wf-1',
executionId: 'exec-1',
errors: [{ nodeName: 'Extract Emails', errorMessage: 'Intentional break' }],
};
function seedThreadArtifact(workflowId = 'wf-1', workflowName = 'My Workflow') {
thread.producedArtifacts = new Map([
[workflowId, { type: 'workflow', id: workflowId, name: workflowName }],
]) as typeof thread.producedArtifacts;
}
async function emitFailure(report: WorkflowFailuresReport = failureReport) {
await vi.waitFor(() => {
expect(workflowPreviewEmit).not.toBeNull();
});
workflowPreviewEmit?.('workflow-failures', report);
}
it('renders the card when the iframe reports a workflow failure', async () => {
seedThreadArtifact();
const { getByTestId, findByTestId } = renderView({ props: { threadId: 'thread-1' } });
await emitFailure();
const panel = await findByTestId('instance-ai-fix-with-ai-panel');
expect(panel).toHaveTextContent('Execution failed in Extract Emails node');
expect(getByTestId('instance-ai-fix-with-ai-button')).toBeInTheDocument();
});
it('hides the card after dismiss', async () => {
seedThreadArtifact();
const user = userEvent.setup();
const { findByTestId, queryByTestId } = renderView({ props: { threadId: 'thread-1' } });
await emitFailure();
await user.click(await findByTestId('instance-ai-fix-with-ai-dismiss'));
await vi.waitFor(() => {
expect(queryByTestId('instance-ai-fix-with-ai-panel')).not.toBeInTheDocument();
});
});
it('sends a fix prompt that names the failed node, error and workflow', async () => {
seedThreadArtifact('wf-1', 'My Workflow');
const user = userEvent.setup();
const { findByTestId } = renderView({ props: { threadId: 'thread-1' } });
await emitFailure();
await user.click(await findByTestId('instance-ai-fix-with-ai-button'));
expect(thread.sendMessage).toHaveBeenCalledOnce();
const [prompt] = vi.mocked(thread.sendMessage).mock.calls[0];
expect(prompt).toContain('Extract Emails');
expect(prompt).toContain('Intentional break');
expect(prompt).toContain('My Workflow');
});
it('hides the card while the chat is busy', async () => {
seedThreadArtifact();
thread.isStreaming = true;
const { queryByTestId } = renderView({ props: { threadId: 'thread-1' } });
await emitFailure();
expect(queryByTestId('instance-ai-fix-with-ai-panel')).not.toBeInTheDocument();
});
});
});

View File

@ -176,7 +176,6 @@ describe('InstanceAiWorkflowPreview', () => {
dispatchIframeMessage({
command: 'reportWorkflowFailures',
workflowId: 'wf-1',
workflowName: 'My Workflow',
executionId: 'exec-1',
errors: [{ nodeName: 'Extract Emails', errorMessage: 'Intentional break' }],
});
@ -188,7 +187,6 @@ describe('InstanceAiWorkflowPreview', () => {
[
{
workflowId: 'wf-1',
workflowName: 'My Workflow',
executionId: 'exec-1',
errors: [{ nodeName: 'Extract Emails', errorMessage: 'Intentional break' }],
},
@ -200,7 +198,6 @@ describe('InstanceAiWorkflowPreview', () => {
const { emitted } = renderComponent({ props: { workflowId: null } });
dispatchIframeMessage({ command: 'reportWorkflowFailures', errors: [] });
dispatchIframeMessage({
command: 'reportWorkflowFailures',
workflowId: 'wf-1',

View File

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { buildFixWithAiPrompt, isFixWithAiError } from '../fixWithAi';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string, options?: { interpolate?: Record<string, string | number> }) => {
if (!options?.interpolate) return key;
return `${key}:${JSON.stringify(options.interpolate)}`;
},
}),
}));
describe('isFixWithAiError', () => {
it('accepts valid error objects', () => {
expect(isFixWithAiError({ nodeName: 'HTTP Request', errorMessage: 'failed' })).toBe(true);
});
it('rejects invalid values', () => {
expect(isFixWithAiError(null)).toBe(false);
expect(isFixWithAiError({ nodeName: 'HTTP Request' })).toBe(false);
expect(isFixWithAiError({ nodeName: 1, errorMessage: 'x' })).toBe(false);
});
});
describe('buildFixWithAiPrompt', () => {
it('builds a single-node prompt with workflow name', () => {
expect(
buildFixWithAiPrompt({
workflowName: 'My Workflow',
errors: [{ nodeName: 'Extract Emails', errorMessage: 'boom' }],
}),
).toContain('instanceAi.fixWithAi.prompt.singleInWorkflow');
});
it('builds a single-node prompt without workflow name', () => {
expect(
buildFixWithAiPrompt({
errors: [{ nodeName: 'Extract Emails', errorMessage: 'boom' }],
}),
).toContain('instanceAi.fixWithAi.prompt.single');
});
it('builds a multi-node prompt with workflow name', () => {
expect(
buildFixWithAiPrompt({
workflowName: 'My Workflow',
errors: [
{ nodeName: 'Node A', errorMessage: 'first' },
{ nodeName: 'Node B', errorMessage: 'second' },
],
}),
).toContain('instanceAi.fixWithAi.prompt.multipleInWorkflow');
});
});

View File

@ -1,100 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildFixWithAiPrompt, isFixWithAiError, useFixWithAiOffer } from '../useFixWithAiOffer';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string, options?: { interpolate?: Record<string, string | number> }) => {
if (!options?.interpolate) return key;
return `${key}:${JSON.stringify(options.interpolate)}`;
},
}),
}));
describe('isFixWithAiError', () => {
it('accepts valid error objects', () => {
expect(isFixWithAiError({ nodeName: 'HTTP Request', errorMessage: 'failed' })).toBe(true);
});
it('rejects invalid values', () => {
expect(isFixWithAiError(null)).toBe(false);
expect(isFixWithAiError({ nodeName: 'HTTP Request' })).toBe(false);
});
});
describe('buildFixWithAiPrompt', () => {
it('builds a single-node prompt with workflow name', () => {
expect(
buildFixWithAiPrompt({
workflowName: 'My Workflow',
errors: [{ nodeName: 'Extract Emails', errorMessage: 'boom' }],
}),
).toContain('instanceAi.fixWithAi.prompt.singleInWorkflow');
});
it('builds a multi-node prompt', () => {
const prompt = buildFixWithAiPrompt({
workflowName: 'My Workflow',
errors: [
{ nodeName: 'Node A', errorMessage: 'first' },
{ nodeName: 'Node B', errorMessage: 'second' },
],
});
expect(prompt).toContain('instanceAi.fixWithAi.prompt.multipleInWorkflow');
});
});
describe('useFixWithAiOffer', () => {
it('registerOffer stores failures reported from the preview iframe', () => {
const { offersByWorkflow, registerOffer } = useFixWithAiOffer();
registerOffer({
workflowId: 'wf-1',
workflowName: 'My Workflow',
executionId: 'exec-99',
errors: [{ nodeName: 'Extract Emails', errorMessage: 'boom' }],
});
expect(offersByWorkflow.value.get('wf-1')).toEqual({
workflowId: 'wf-1',
workflowName: 'My Workflow',
executionId: 'exec-99',
errors: [{ nodeName: 'Extract Emails', errorMessage: 'boom' }],
});
});
it('ignores registerOffer when errors array is empty', () => {
const { offersByWorkflow, registerOffer } = useFixWithAiOffer();
registerOffer({
workflowId: 'wf-1',
executionId: 'exec-99',
errors: [],
});
expect(offersByWorkflow.value.has('wf-1')).toBe(false);
});
it('tracks dismiss state by execution id', () => {
const { dismiss, isDismissed } = useFixWithAiOffer();
dismiss('exec-1');
expect(isDismissed('exec-1')).toBe(true);
expect(isDismissed('exec-2')).toBe(false);
});
it('cleanup clears offers and dismiss state', () => {
const { offersByWorkflow, registerOffer, dismiss, cleanup, isDismissed } = useFixWithAiOffer();
registerOffer({
workflowId: 'wf-1',
executionId: 'exec-1',
errors: [{ nodeName: 'Node', errorMessage: 'failed' }],
});
dismiss('exec-1');
cleanup();
expect(offersByWorkflow.value.size).toBe(0);
expect(isDismissed('exec-1')).toBe(false);
});
});

View File

@ -6,11 +6,7 @@ import type { PushMessage } from '@n8n/api-types';
import WorkflowPreview from '@/app/components/WorkflowPreview.vue';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import type { IWorkflowDb } from '@/Interface';
import {
isFixWithAiError,
type FixWithAiError,
type FixWithAiOfferState,
} from '../useFixWithAiOffer';
import { isFixWithAiError, type FixWithAiError } from '../fixWithAi';
const props = withDefaults(
defineProps<{
@ -21,7 +17,11 @@ const props = withDefaults(
{ refreshKey: 0 },
);
export type WorkflowFailuresReport = FixWithAiOfferState;
export interface WorkflowFailuresReport {
workflowId: string;
executionId: string;
errors: FixWithAiError[];
}
const emit = defineEmits<{
'iframe-ready': [];
@ -49,11 +49,6 @@ function getPreviewIframe(): HTMLIFrameElement | null {
);
}
function parseWorkflowFailureErrors(errors: unknown): FixWithAiError[] {
if (!Array.isArray(errors)) return [];
return errors.filter(isFixWithAiError);
}
function isMessageFromPreviewIframe(event: MessageEvent): boolean {
if (event.origin !== window.location.origin) return false;
@ -63,6 +58,11 @@ function isMessageFromPreviewIframe(event: MessageEvent): boolean {
return event.source === iframeWindow;
}
function parseWorkflowFailureErrors(errors: unknown): FixWithAiError[] {
if (!Array.isArray(errors)) return [];
return errors.filter(isFixWithAiError);
}
function handleIframeMessage(event: MessageEvent) {
if (!isMessageFromPreviewIframe(event)) return;
if (typeof event.data !== 'string' || !event.data.includes('"command"')) return;
@ -81,7 +81,6 @@ function handleIframeMessage(event: MessageEvent) {
}
emit('workflow-failures', {
workflowId: json.workflowId,
workflowName: typeof json.workflowName === 'string' ? json.workflowName : undefined,
executionId: json.executionId,
errors,
});

View File

@ -1,4 +1,3 @@
import { ref } from 'vue';
import { useI18n } from '@n8n/i18n';
export interface FixWithAiError {
@ -6,13 +5,6 @@ export interface FixWithAiError {
errorMessage: string;
}
export interface FixWithAiOfferState {
workflowId: string;
workflowName?: string;
executionId: string;
errors: FixWithAiError[];
}
export function isFixWithAiError(value: unknown): value is FixWithAiError {
return (
typeof value === 'object' &&
@ -22,9 +14,10 @@ export function isFixWithAiError(value: unknown): value is FixWithAiError {
);
}
export function buildFixWithAiPrompt(
context: Pick<FixWithAiOfferState, 'workflowName' | 'errors'>,
): string {
export function buildFixWithAiPrompt(context: {
workflowName?: string;
errors: FixWithAiError[];
}): string {
const i18n = useI18n();
if (context.errors.length === 1) {
@ -59,37 +52,3 @@ export function buildFixWithAiPrompt(
interpolate: { errorList },
});
}
export function useFixWithAiOffer() {
const offersByWorkflow = ref(new Map<string, FixWithAiOfferState>());
const dismissedExecutionIds = ref(new Set<string>());
function registerOffer(input: FixWithAiOfferState) {
if (input.errors.length === 0) return;
const next = new Map(offersByWorkflow.value);
next.set(input.workflowId, input);
offersByWorkflow.value = next;
}
function dismiss(executionId: string) {
dismissedExecutionIds.value = new Set([...dismissedExecutionIds.value, executionId]);
}
function isDismissed(executionId: string): boolean {
return dismissedExecutionIds.value.has(executionId);
}
function cleanup() {
offersByWorkflow.value = new Map();
dismissedExecutionIds.value = new Set();
}
return {
offersByWorkflow,
registerOffer,
dismiss,
isDismissed,
cleanup,
};
}