mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
fix(editor): Prevent AI Assistant verification fixtures from being pinned as real data (#36653)
This commit is contained in:
parent
85869e8436
commit
fe79e4ab41
|
|
@ -61,6 +61,7 @@ function createMockContext(
|
|||
nodes: [],
|
||||
connections: {},
|
||||
}),
|
||||
getPinnedDataSummary: vi.fn().mockResolvedValue([]),
|
||||
createFromWorkflowJSON: vi.fn(),
|
||||
updateFromWorkflowJSON: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
|
|
@ -802,6 +803,41 @@ describe('workflows tool', () => {
|
|||
expect(result).toEqual(workflow);
|
||||
});
|
||||
|
||||
it('reports pinned nodes next to the JSON when the workflow carries pinned data', async () => {
|
||||
const workflow = { id: 'wf1', name: 'Test WF', nodes: [], connections: {} };
|
||||
const context = createMockContext();
|
||||
(context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(workflow);
|
||||
(context.workflowService.getPinnedDataSummary as Mock).mockResolvedValue([
|
||||
{ nodeName: 'Get Job Alert Emails', itemCount: 1 },
|
||||
]);
|
||||
|
||||
const tool = createWorkflowsTool(context, 'full');
|
||||
const result = await executeTool(
|
||||
tool,
|
||||
{ action: 'get-json', workflowId: 'wf1' },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
...workflow,
|
||||
pinnedNodes: [{ nodeName: 'Get Job Alert Emails', itemCount: 1 }],
|
||||
});
|
||||
expect(result).toHaveProperty('pinnedDataNote', expect.stringContaining('pinned data'));
|
||||
});
|
||||
|
||||
it('does not fetch a pin report for historical version reads', async () => {
|
||||
const context = createMockContext();
|
||||
const tool = createWorkflowsTool(context, 'full');
|
||||
|
||||
await executeTool(
|
||||
tool,
|
||||
{ action: 'get-json', workflowId: 'wf1', versionId: 'v7' },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(context.workflowService.getPinnedDataSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should forward versionId to the full fetches', async () => {
|
||||
const context = createMockContext();
|
||||
const tool = createWorkflowsTool(context, 'full');
|
||||
|
|
|
|||
|
|
@ -273,3 +273,51 @@ describe('analyzeVerificationResult — chat model failures', () => {
|
|||
expect(analysis.remediation?.guidance).not.toContain('n8n credits');
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeVerificationResult — workflow-pinned nodes', () => {
|
||||
const pinnedRunResult = {
|
||||
executionId: 'exec-1',
|
||||
status: 'success',
|
||||
executedNodeNames: ['Trigger', 'Get Job Alert Emails', 'Mark Email Processed'],
|
||||
lastNodeExecuted: 'Mark Email Processed',
|
||||
data: {
|
||||
Trigger: [{}],
|
||||
'Get Job Alert Emails': [{ id: 'msg_1' }],
|
||||
'Mark Email Processed': [{}],
|
||||
},
|
||||
workflowPinnedNodeNames: ['Get Job Alert Emails', 'Unreached Pinned Node'],
|
||||
} as unknown as ExecutionRunResult;
|
||||
|
||||
it('counts reached pinned nodes as simulated so the run is not treated as live', () => {
|
||||
const analysis = analyzeVerificationResult({
|
||||
result: pinnedRunResult,
|
||||
buildOutcome: makeBuildOutcome(),
|
||||
simulatedNodes: [],
|
||||
stateBefore: undefined,
|
||||
runId: 'run-1',
|
||||
});
|
||||
|
||||
expect(analysis.reachedSimulatedNodes).toEqual([
|
||||
{
|
||||
nodeName: 'Get Job Alert Emails',
|
||||
reason: 'Output came from pinned data saved on the workflow — unpin it for a live test',
|
||||
},
|
||||
]);
|
||||
expect(analysis.simulationNote).toContain('Get Job Alert Emails');
|
||||
expect(analysis.simulationNote).toContain('pinned data saved on the workflow');
|
||||
});
|
||||
|
||||
it('does not duplicate nodes the simulation plan already covers', () => {
|
||||
const analysis = analyzeVerificationResult({
|
||||
result: pinnedRunResult,
|
||||
buildOutcome: makeBuildOutcome(),
|
||||
simulatedNodes: [{ nodeName: 'Get Job Alert Emails', reason: 'Mocked credentials' }],
|
||||
stateBefore: undefined,
|
||||
runId: 'run-1',
|
||||
});
|
||||
|
||||
expect(analysis.reachedSimulatedNodes).toEqual([
|
||||
{ nodeName: 'Get Job Alert Emails', reason: 'Mocked credentials' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -430,7 +430,20 @@ export function analyzeVerificationResult(args: {
|
|||
const reachedNames = new Set(
|
||||
result.executedNodeNames ?? (result.data ? Object.keys(result.data) : []),
|
||||
);
|
||||
const reachedSimulatedNodes = simulatedNodes.filter((n) => reachedNames.has(n.nodeName));
|
||||
// Nodes fed by pinned data saved on the workflow did not really execute
|
||||
// either — count them as simulated so a pin-fed run never passes as a live
|
||||
// test (INS-1216: stale AI fixtures adopted as pins looked fully verified).
|
||||
const plannedSimulatedNames = new Set(simulatedNodes.map((n) => n.nodeName));
|
||||
const workflowPinnedNodes = (result.workflowPinnedNodeNames ?? [])
|
||||
.filter((name) => reachedNames.has(name) && !plannedSimulatedNames.has(name))
|
||||
.map((name) => ({
|
||||
nodeName: name,
|
||||
reason: 'Output came from pinned data saved on the workflow — unpin it for a live test',
|
||||
}));
|
||||
const reachedSimulatedNodes = [
|
||||
...simulatedNodes.filter((n) => reachedNames.has(n.nodeName)),
|
||||
...workflowPinnedNodes,
|
||||
];
|
||||
const nodesNotReached = (buildOutcome.nodeSimulationPlan ?? [])
|
||||
.map((verdict) => verdict.nodeName)
|
||||
.filter((name) => !reachedNames.has(name));
|
||||
|
|
|
|||
|
|
@ -557,6 +557,35 @@ async function handleGet(context: InstanceAiContext, input: Extract<Input, { act
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinned-data summary for agent visibility. Pins live on the saved workflow but
|
||||
* never inside the WorkflowJSON the agent round-trips (see
|
||||
* `InstanceAiWorkflowService.getPinnedDataSummary`), so without this report the
|
||||
* agent cannot tell that test runs feed nodes from saved pins instead of
|
||||
* executing them. Failures degrade to "no report" — it must never break a read.
|
||||
*/
|
||||
async function getPinnedNodesReport(
|
||||
context: InstanceAiContext,
|
||||
workflowId: string,
|
||||
): Promise<
|
||||
| { pinnedNodes: Array<{ nodeName: string; itemCount: number }>; pinnedDataNote: string }
|
||||
| undefined
|
||||
> {
|
||||
try {
|
||||
const pinnedNodes = await context.workflowService.getPinnedDataSummary?.(workflowId);
|
||||
if (!pinnedNodes?.length) return undefined;
|
||||
return {
|
||||
pinnedNodes,
|
||||
pinnedDataNote:
|
||||
'These nodes have pinned data saved on the workflow (not part of the JSON). ' +
|
||||
'Test executions output the pinned items instead of running these nodes, so such runs are not live tests of them. ' +
|
||||
'If the pins are stale or AI-simulated sample data, ask the user to unpin them; rebuilding the workflow also clears them.',
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGetJson(
|
||||
context: InstanceAiContext,
|
||||
input: Extract<Input, { action: 'get-json' }>,
|
||||
|
|
@ -568,7 +597,10 @@ async function handleGetJson(
|
|||
if (!input.versionId) {
|
||||
await rememberCurrentWorkflowChecksum(context, input.workflowId);
|
||||
}
|
||||
return json;
|
||||
const pinnedReport = input.versionId
|
||||
? undefined
|
||||
: await getPinnedNodesReport(context, input.workflowId);
|
||||
return pinnedReport ? { ...json, ...pinnedReport } : json;
|
||||
} catch (error) {
|
||||
return {
|
||||
workflowId: input.workflowId,
|
||||
|
|
@ -1210,10 +1242,14 @@ async function handleValidate(
|
|||
input: Extract<Input, { action: 'validate' }>,
|
||||
) {
|
||||
try {
|
||||
return await validateWorkflowConfig(context, {
|
||||
const result = await validateWorkflowConfig(context, {
|
||||
workflowId: input.workflowId,
|
||||
ignoreIssues: input.ignoreIssues,
|
||||
});
|
||||
// Pins are not validity issues, but a "valid" report that hides them lets
|
||||
// the agent misread pin-fed test runs as live ones (INS-1216).
|
||||
const pinnedReport = await getPinnedNodesReport(context, input.workflowId);
|
||||
return pinnedReport ? { ...result, ...pinnedReport } : result;
|
||||
} catch (error) {
|
||||
return {
|
||||
workflowId: input.workflowId,
|
||||
|
|
|
|||
|
|
@ -110,6 +110,12 @@ export interface ExecutionResult {
|
|||
* nothing" apart from "never reached".
|
||||
*/
|
||||
executedNodeNames?: string[];
|
||||
/**
|
||||
* Nodes with pinned data saved on the workflow when this run started. A
|
||||
* reached node in this list output its pinned items instead of executing,
|
||||
* so the run is not a live test of it.
|
||||
*/
|
||||
workflowPinnedNodeNames?: string[];
|
||||
/** Node-level errors from run data, including continue-on-fail errors. */
|
||||
nodeErrors?: ExecutionNodeError[];
|
||||
/** Name of the last node the execution processed, when available. */
|
||||
|
|
@ -322,6 +328,16 @@ export interface InstanceAiWorkflowService {
|
|||
/** Get the workflow as the SDK's WorkflowJSON (full node data for generateWorkflowCode).
|
||||
* Pass a versionId to get a past version's graph instead of the current draft. */
|
||||
getAsWorkflowJSON(workflowId: string, versionId?: string): Promise<WorkflowJSON>;
|
||||
/**
|
||||
* Names and item counts of nodes carrying pinned data on the saved workflow.
|
||||
* Deliberately a summary next to the WorkflowJSON rather than part of it:
|
||||
* pin payloads can be huge, and a JSON that round-trips through
|
||||
* `updateFromWorkflowJSON` must stay pin-free so agent saves keep clearing
|
||||
* stale pins instead of re-persisting them.
|
||||
*/
|
||||
getPinnedDataSummary?(
|
||||
workflowId: string,
|
||||
): Promise<Array<{ nodeName: string; itemCount: number }>>;
|
||||
/** Cheap version-only lookup. The adapter projects just `versionId` and
|
||||
* `updatedAt` from the workflow row, skipping `nodes`/`connections`/etc.
|
||||
* Use to validate per-session caches when the body isn't needed. */
|
||||
|
|
|
|||
|
|
@ -3,6 +3,36 @@ import { parseWorkflowCode } from './parse-workflow-code';
|
|||
import type { WorkflowJSON } from '../types/base';
|
||||
|
||||
describe('generateWorkflowCode', () => {
|
||||
it('never emits pinData from the source workflow', () => {
|
||||
// Intentional (INS-1216): pinned data must not round-trip through
|
||||
// generated code. get-as-code → edit → rebuild is how the AI Assistant
|
||||
// clears stale pins (fabricated verification fixtures a user adopted);
|
||||
// if codegen represented pins, every rebuild would re-persist them.
|
||||
const json: WorkflowJSON = {
|
||||
id: 'wf-pins',
|
||||
name: 'Pinned Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
name: 'Get Job Alert Emails',
|
||||
type: 'n8n-nodes-base.gmail',
|
||||
typeVersion: 2.1,
|
||||
position: [0, 0],
|
||||
parameters: { resource: 'message', operation: 'getAll' },
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
pinData: {
|
||||
'Get Job Alert Emails': [{ id: 'msg_1', threadId: 'th_1' }],
|
||||
},
|
||||
};
|
||||
|
||||
const code = generateWorkflowCode(json);
|
||||
|
||||
expect(code).not.toContain('pinData');
|
||||
expect(code).not.toContain('msg_1');
|
||||
});
|
||||
|
||||
it('should generate valid TypeScript for a simple workflow', () => {
|
||||
const json: WorkflowJSON = {
|
||||
id: 'test-123',
|
||||
|
|
|
|||
|
|
@ -2079,6 +2079,29 @@ describe('createWorkflowAdapter', () => {
|
|||
mockedUserHasScopes.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('summarizes pinned data as node names and item counts, without payloads', async () => {
|
||||
const { adapter, mockWorkflowFinderService } = createWorkflowAdapterForTests();
|
||||
mockWorkflowFinderService.findWorkflowForUser.mockResolvedValue({
|
||||
id: 'wf-pins',
|
||||
pinData: {
|
||||
'Get Job Alert Emails': [{ json: { id: 'msg_1' } }, { json: { id: 'msg_2' } }],
|
||||
'Empty Pin': [],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(adapter.getPinnedDataSummary?.('wf-pins')).resolves.toEqual([
|
||||
{ nodeName: 'Get Job Alert Emails', itemCount: 2 },
|
||||
{ nodeName: 'Empty Pin', itemCount: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty pinned-data summary when the workflow has no pins', async () => {
|
||||
const { adapter, mockWorkflowFinderService } = createWorkflowAdapterForTests();
|
||||
mockWorkflowFinderService.findWorkflowForUser.mockResolvedValue({ id: 'wf-clean' });
|
||||
|
||||
await expect(adapter.getPinnedDataSummary?.('wf-clean')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves node-level execution options when returning WorkflowJSON', async () => {
|
||||
const { adapter, mockWorkflowFinderService } = createWorkflowAdapterForTests();
|
||||
mockWorkflowFinderService.findWorkflowForUser.mockResolvedValue({
|
||||
|
|
@ -2551,6 +2574,30 @@ describe('createWorkflowAdapter', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('notifies open editors after a successful update so stale canvases reload', async () => {
|
||||
// Without this an open editor keeps state the save replaced (e.g. cleared
|
||||
// pinned data) and resurrects it via the overwrite-conflict dialog.
|
||||
const { adapter, mockCollaborationService, mockUser } = createWorkflowAdapterForTests();
|
||||
|
||||
await adapter.updateFromWorkflowJSON('wf-existing', minimalWorkflowJSON);
|
||||
|
||||
expect(mockCollaborationService.broadcastWorkflowUpdate).toHaveBeenCalledWith(
|
||||
'wf-existing',
|
||||
mockUser.id,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not notify editors when the update fails', async () => {
|
||||
const { adapter, mockCollaborationService, mockWorkflowService } =
|
||||
createWorkflowAdapterForTests();
|
||||
mockWorkflowService.update.mockRejectedValueOnce(new Error('save failed'));
|
||||
|
||||
await expect(
|
||||
adapter.updateFromWorkflowJSON('wf-existing', minimalWorkflowJSON),
|
||||
).rejects.toThrow('save failed');
|
||||
expect(mockCollaborationService.broadcastWorkflowUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears existing node groups when the SDK workflow declares none (update is authoritative)', async () => {
|
||||
// Regression: the SDK omits `nodeGroups` when no `.group(...)` is declared. The
|
||||
// update path must treat that as "no groups" and send [] so a removed group is
|
||||
|
|
@ -3392,6 +3439,32 @@ describe('createExecutionAdapter run()', () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reports workflow-pinned nodes on the run result', async () => {
|
||||
const { adapter } = createRunAdapterForTests(
|
||||
{
|
||||
id: 'wf-1',
|
||||
nodes: [],
|
||||
pinData: { 'Get Job Alert Emails': [{ json: { id: 'msg_1' } }] },
|
||||
},
|
||||
{ execution: makeExecution({ status: 'success' }) },
|
||||
);
|
||||
|
||||
const result = await adapter.run('wf-1');
|
||||
|
||||
expect(result.workflowPinnedNodeNames).toEqual(['Get Job Alert Emails']);
|
||||
});
|
||||
|
||||
it('omits workflow-pinned nodes from the run result when the workflow has none', async () => {
|
||||
const { adapter } = createRunAdapterForTests(
|
||||
{ id: 'wf-1', nodes: [] },
|
||||
{ execution: makeExecution({ status: 'success' }) },
|
||||
);
|
||||
|
||||
const result = await adapter.run('wf-1');
|
||||
|
||||
expect(result).not.toHaveProperty('workflowPinnedNodeNames');
|
||||
});
|
||||
|
||||
it('forces save settings so the agent can read the result back', async () => {
|
||||
const { adapter, mockWorkflowRunner } = createRunAdapterForTests({
|
||||
id: 'wf-1',
|
||||
|
|
|
|||
|
|
@ -811,6 +811,17 @@ export class InstanceAiAdapterService {
|
|||
return toWorkflowJSON(wf, { redactParameters, graph: version });
|
||||
},
|
||||
|
||||
async getPinnedDataSummary(workflowId: string) {
|
||||
const wf = await workflowFinderService.findWorkflowForUser(workflowId, user, [
|
||||
'workflow:read',
|
||||
]);
|
||||
if (!wf) throw new WorkflowNotFoundError(workflowId);
|
||||
return Object.entries(wf.pinData ?? {}).map(([nodeName, items]) => ({
|
||||
nodeName,
|
||||
itemCount: Array.isArray(items) ? items.length : 0,
|
||||
}));
|
||||
},
|
||||
|
||||
async getWorkflowHead(workflowId: string) {
|
||||
const head = await workflowFinderService.findWorkflowHeadForUser(workflowId, user, [
|
||||
'workflow:read',
|
||||
|
|
@ -1057,6 +1068,9 @@ export class InstanceAiAdapterService {
|
|||
});
|
||||
}
|
||||
|
||||
// Tell open editors to reload so a clean canvas picks up state this
|
||||
// save replaced (e.g. cleared pinned data). A dirty canvas keeps its
|
||||
// local changes and resolves them via the conflict dialog (INS-1216).
|
||||
await notifyWorkflowUpdated(workflowId);
|
||||
|
||||
return await toWorkflowDetailWithChecksum(updated, { redactParameters });
|
||||
|
|
@ -1492,7 +1506,12 @@ export class InstanceAiAdapterService {
|
|||
const result = await extractExecutionResult(executionId, allowSendingParameterValues);
|
||||
await pruneVerificationPins(result.executedNodeNames);
|
||||
trackBuilderExecutedWorkflow(result.status, result.error);
|
||||
return result;
|
||||
// Saved workflow pins fed this run (they ride every instance-ai run) —
|
||||
// report them so callers don't mistake pin-fed nodes for live ones.
|
||||
const workflowPinnedNodeNames = Object.keys(workflow.pinData ?? {});
|
||||
return workflowPinnedNodeNames.length > 0
|
||||
? { ...result, workflowPinnedNodeNames }
|
||||
: result;
|
||||
} catch (error) {
|
||||
// A failure to launch (or any other unsettled error) is still an
|
||||
// errored builder run — track it before rethrowing so it isn't
|
||||
|
|
|
|||
|
|
@ -2046,6 +2046,9 @@
|
|||
"ndv.pinData.unpinAndExecute.description": "Testing a node overwrites pinned data.",
|
||||
"ndv.pinData.unpinAndExecute.cancel": "Cancel",
|
||||
"ndv.pinData.unpinAndExecute.confirm": "Unpin and test",
|
||||
"ndv.pinData.aiSimulated.confirm.title": "Pin simulated data?",
|
||||
"ndv.pinData.aiSimulated.confirm.description": "The AI Assistant made this data up to test the workflow without calling real services. If you pin it, test runs will use it instead of real data. Any IDs, emails or records in it don't exist.",
|
||||
"ndv.pinData.aiSimulated.confirm.confirmButtonText": "Pin anyway",
|
||||
"ndv.pinData.beforeClosing.title": "Save output changes before closing?",
|
||||
"ndv.pinData.beforeClosing.cancel": "Discard",
|
||||
"ndv.pinData.beforeClosing.confirm": "Save",
|
||||
|
|
@ -3172,6 +3175,7 @@
|
|||
"runData.table.viewSubExecution": "View sub-execution {id}",
|
||||
"runData.pindata.learnMore": "Learn more",
|
||||
"runData.pindata.thisDataIsPinned": "This data is pinned for test executions.",
|
||||
"runData.aiSimulatedData.callout": "This data was made up by the AI Assistant to test the workflow. It is not real data.",
|
||||
"runData.pindata.unpin": "Unpin",
|
||||
"runData.editor.save": "Save",
|
||||
"runData.editor.cancel": "Cancel",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import { MODAL_CONFIRM } from '@/app/constants';
|
||||
import { useAiSimulatedExecutionsStore } from '@/app/stores/aiSimulatedExecutions.store';
|
||||
|
||||
/**
|
||||
* Guard for adopting AI-simulated execution output as pinned data. Output of a
|
||||
* node the AI Assistant simulated during workflow verification is fabricated
|
||||
* sample data; every surface that turns displayed output into workflow pins
|
||||
* asks the same explicit confirmation through this guard.
|
||||
*/
|
||||
export function useAiSimulatedDataGuard() {
|
||||
const aiSimulatedExecutionsStore = useAiSimulatedExecutionsStore();
|
||||
const message = useMessage();
|
||||
const i18n = useI18n();
|
||||
|
||||
function isSimulatedNodeOutput(
|
||||
executionId: string | undefined,
|
||||
nodeName: string | undefined,
|
||||
): boolean {
|
||||
return aiSimulatedExecutionsStore.isSimulatedNodeOutput(executionId, nodeName);
|
||||
}
|
||||
|
||||
async function confirmAdoption(): Promise<boolean> {
|
||||
const answer = await message.confirm(
|
||||
i18n.baseText('ndv.pinData.aiSimulated.confirm.description'),
|
||||
i18n.baseText('ndv.pinData.aiSimulated.confirm.title'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: i18n.baseText('ndv.pinData.aiSimulated.confirm.confirmButtonText'),
|
||||
cancelButtonText: i18n.baseText('generic.cancel'),
|
||||
},
|
||||
);
|
||||
return answer === MODAL_CONFIRM;
|
||||
}
|
||||
|
||||
return { isSimulatedNodeOutput, confirmAdoption };
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
import { useDataSchema } from '@/app/composables/useDataSchema';
|
||||
import { useExternalHooks } from '@/app/composables/useExternalHooks';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useAiSimulatedDataGuard } from '@/app/composables/useAiSimulatedDataGuard';
|
||||
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
|
||||
import { getN8nAgentsNodeName } from '@/experiments/inlineAgents/useInlineAgentsExperiment';
|
||||
import { type PinDataSource, usePinnedData } from '@/app/composables/usePinnedData';
|
||||
|
|
@ -255,6 +256,7 @@ export function useCanvasOperations() {
|
|||
const toast = useToast();
|
||||
const workflowHelpers = useWorkflowHelpers();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const aiSimulatedDataGuard = useAiSimulatedDataGuard();
|
||||
const {
|
||||
requireNodeTypeDescription,
|
||||
resolveNodeParameters,
|
||||
|
|
@ -923,15 +925,11 @@ export function useCanvasOperations() {
|
|||
}
|
||||
}
|
||||
|
||||
function toggleNodesPinned(
|
||||
async function toggleNodesPinned(
|
||||
ids: string[],
|
||||
source: PinDataSource,
|
||||
{ trackHistory = true, trackBulk = true } = {},
|
||||
) {
|
||||
if (trackHistory && trackBulk) {
|
||||
historyStore.startRecordingUndo();
|
||||
}
|
||||
|
||||
const nodes = workflowDocumentStore.value.getNodesByIds(ids);
|
||||
|
||||
// Filter to only pinnable nodes
|
||||
|
|
@ -943,6 +941,27 @@ export function useCanvasOperations() {
|
|||
({ pinnedData }) => !pinnedData.hasData.value,
|
||||
);
|
||||
|
||||
// Pinning copies the displayed output; when that output was simulated by
|
||||
// the AI Assistant during verification it is fabricated sample data, so
|
||||
// adopting it needs the same explicit opt-in as the NDV pin button.
|
||||
if (nextStatePinned) {
|
||||
const displayedExecutionId = useWorkflowExecutionStateStore(
|
||||
workflowDocumentStore.value.documentId,
|
||||
).activeExecution?.id;
|
||||
const adoptsSimulatedData = pinnableNodesWithPinnedData.some(
|
||||
({ node, pinnedData }) =>
|
||||
!pinnedData.hasData.value &&
|
||||
aiSimulatedDataGuard.isSimulatedNodeOutput(displayedExecutionId, node.name),
|
||||
);
|
||||
if (adoptsSimulatedData && !(await aiSimulatedDataGuard.confirmAdoption())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (trackHistory && trackBulk) {
|
||||
historyStore.startRecordingUndo();
|
||||
}
|
||||
|
||||
for (const { node, pinnedData: pinnedDataForNode } of pinnableNodesWithPinnedData) {
|
||||
if (nextStatePinned) {
|
||||
const dataToPin = useDataSchema().getInputDataWithPinned(node);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
import { useAiSimulatedExecutionsStore } from '@/app/stores/aiSimulatedExecutions.store';
|
||||
|
||||
describe('aiSimulatedExecutions store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it('reports a marked node of a marked execution as simulated', () => {
|
||||
const store = useAiSimulatedExecutionsStore();
|
||||
store.markSimulatedNodes('exec-1', ['Gmail', 'HTTP Request']);
|
||||
|
||||
expect(store.isSimulatedNodeOutput('exec-1', 'Gmail')).toBe(true);
|
||||
expect(store.isSimulatedNodeOutput('exec-1', 'HTTP Request')).toBe(true);
|
||||
});
|
||||
|
||||
it('reports unmarked nodes and unknown executions as not simulated', () => {
|
||||
const store = useAiSimulatedExecutionsStore();
|
||||
store.markSimulatedNodes('exec-1', ['Gmail']);
|
||||
|
||||
expect(store.isSimulatedNodeOutput('exec-1', 'Set')).toBe(false);
|
||||
expect(store.isSimulatedNodeOutput('exec-2', 'Gmail')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles missing execution id or node name', () => {
|
||||
const store = useAiSimulatedExecutionsStore();
|
||||
store.markSimulatedNodes('exec-1', ['Gmail']);
|
||||
|
||||
expect(store.isSimulatedNodeOutput(undefined, 'Gmail')).toBe(false);
|
||||
expect(store.isSimulatedNodeOutput('exec-1', undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores marking with an empty node list', () => {
|
||||
const store = useAiSimulatedExecutionsStore();
|
||||
store.markSimulatedNodes('exec-1', []);
|
||||
|
||||
expect(store.isSimulatedNodeOutput('exec-1', 'Gmail')).toBe(false);
|
||||
});
|
||||
|
||||
it('evicts the oldest executions beyond the retention bound', () => {
|
||||
const store = useAiSimulatedExecutionsStore();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
store.markSimulatedNodes(`exec-${i}`, ['Gmail']);
|
||||
}
|
||||
|
||||
expect(store.isSimulatedNodeOutput('exec-0', 'Gmail')).toBe(false);
|
||||
expect(store.isSimulatedNodeOutput('exec-4', 'Gmail')).toBe(false);
|
||||
expect(store.isSimulatedNodeOutput('exec-5', 'Gmail')).toBe(true);
|
||||
expect(store.isSimulatedNodeOutput('exec-29', 'Gmail')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a re-marked execution alive past eviction', () => {
|
||||
const store = useAiSimulatedExecutionsStore();
|
||||
store.markSimulatedNodes('exec-keep', ['Gmail']);
|
||||
for (let i = 0; i < 24; i++) {
|
||||
store.markSimulatedNodes(`exec-${i}`, ['Gmail']);
|
||||
}
|
||||
store.markSimulatedNodes('exec-keep', ['Gmail']);
|
||||
store.markSimulatedNodes('exec-new', ['Gmail']);
|
||||
|
||||
expect(store.isSimulatedNodeOutput('exec-keep', 'Gmail')).toBe(true);
|
||||
expect(store.isSimulatedNodeOutput('exec-0', 'Gmail')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
/**
|
||||
* Tracks which node outputs of an execution were simulated by the AI Assistant
|
||||
* during workflow verification (fabricated fixture data, not real service
|
||||
* responses). Written by the Instance AI preview when it displays an agent
|
||||
* execution; read by the NDV output panel to label simulated data and guard
|
||||
* against pinning it as if it were real.
|
||||
*
|
||||
* Display metadata only — deliberately kept out of execution data
|
||||
* (`resultData`), which stays a faithful record of what ran.
|
||||
*/
|
||||
/**
|
||||
* Only this many most recently marked executions are retained. The editor can
|
||||
* only re-display recent agent executions, and the bound keeps a long-lived
|
||||
* session with many verification runs from accumulating entries forever.
|
||||
*/
|
||||
const MAX_TRACKED_EXECUTIONS = 25;
|
||||
|
||||
export const useAiSimulatedExecutionsStore = defineStore('aiSimulatedExecutions', () => {
|
||||
const simulatedNodesByExecutionId = ref(new Map<string, Set<string>>());
|
||||
|
||||
function markSimulatedNodes(executionId: string, nodeNames: string[]) {
|
||||
if (nodeNames.length === 0) return;
|
||||
// Delete before set so a re-marked execution moves to the newest position.
|
||||
simulatedNodesByExecutionId.value.delete(executionId);
|
||||
simulatedNodesByExecutionId.value.set(executionId, new Set(nodeNames));
|
||||
while (simulatedNodesByExecutionId.value.size > MAX_TRACKED_EXECUTIONS) {
|
||||
const oldest = simulatedNodesByExecutionId.value.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
simulatedNodesByExecutionId.value.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function isSimulatedNodeOutput(
|
||||
executionId: string | undefined,
|
||||
nodeName: string | undefined,
|
||||
): boolean {
|
||||
if (!executionId || !nodeName) return false;
|
||||
return simulatedNodesByExecutionId.value.get(executionId)?.has(nodeName) ?? false;
|
||||
}
|
||||
|
||||
return {
|
||||
markSimulatedNodes,
|
||||
isSimulatedNodeOutput,
|
||||
};
|
||||
});
|
||||
|
|
@ -675,7 +675,7 @@ function onPinNodes(ids: string[], source: PinDataSource) {
|
|||
return;
|
||||
}
|
||||
|
||||
toggleNodesPinned(ids, source);
|
||||
void toggleNodesPinned(ids, source);
|
||||
}
|
||||
|
||||
function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
|
||||
|
|
|
|||
|
|
@ -885,6 +885,53 @@ describe('getExecutionResultsByWorkflow', () => {
|
|||
expect(results.get('wf-1')).toEqual({ executionId: 'exec-1', status: 'success' });
|
||||
});
|
||||
|
||||
test('extracts simulated node names from a verify-built-workflow result', () => {
|
||||
const node = makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolName: 'verify-built-workflow',
|
||||
args: { workflowId: 'wf-1' },
|
||||
result: {
|
||||
executionId: 'exec-1',
|
||||
status: 'success',
|
||||
simulatedNodes: [
|
||||
{ nodeName: 'Gmail', reason: 'Mocked credentials' },
|
||||
{ nodeName: 'HTTP Request', reason: 'Declared output fixture' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const results = getExecutionResultsByWorkflow(node);
|
||||
expect(results.get('wf-1')?.simulatedNodeNames).toEqual(['Gmail', 'HTTP Request']);
|
||||
});
|
||||
|
||||
test('omits simulatedNodeNames when absent or malformed', () => {
|
||||
const node = makeAgentNode({
|
||||
toolCalls: [
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'verify-built-workflow',
|
||||
args: { workflowId: 'wf-1' },
|
||||
result: { executionId: 'exec-1', status: 'success' },
|
||||
}),
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-2',
|
||||
toolName: 'verify-built-workflow',
|
||||
args: { workflowId: 'wf-2' },
|
||||
result: {
|
||||
executionId: 'exec-2',
|
||||
status: 'success',
|
||||
simulatedNodes: ['not-an-object', { reason: 'missing nodeName' }, null],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const results = getExecutionResultsByWorkflow(node);
|
||||
expect(results.get('wf-1')).not.toHaveProperty('simulatedNodeNames');
|
||||
expect(results.get('wf-2')).not.toHaveProperty('simulatedNodeNames');
|
||||
});
|
||||
|
||||
test('extracts error run-workflow result', () => {
|
||||
const node = makeAgentNode({
|
||||
toolCalls: [
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ export interface ExecutionResult {
|
|||
status: 'success' | 'error';
|
||||
/** ISO timestamp from the run-workflow tool result. Used to detect stale executions. */
|
||||
finishedAt?: string;
|
||||
/**
|
||||
* Nodes whose output in this execution was simulated (fabricated fixture
|
||||
* data) during AI Assistant verification. Used to label that data in the
|
||||
* editor and guard against pinning it as if it were real.
|
||||
*/
|
||||
simulatedNodeNames?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -561,12 +567,14 @@ function collectExecutionResults(node: InstanceAiAgentNode, results: Map<string,
|
|||
'status' in result &&
|
||||
(result.status === 'success' || result.status === 'error')
|
||||
) {
|
||||
const simulatedNodeNames = getSimulatedNodeNames(result);
|
||||
results.set(args.workflowId, {
|
||||
executionId: result.executionId,
|
||||
status: result.status,
|
||||
...('finishedAt' in result && typeof result.finishedAt === 'string'
|
||||
? { finishedAt: result.finishedAt }
|
||||
: {}),
|
||||
...(simulatedNodeNames.length > 0 ? { simulatedNodeNames } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -574,3 +582,13 @@ function collectExecutionResults(node: InstanceAiAgentNode, results: Map<string,
|
|||
collectExecutionResults(child, results);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulated node names from a verify-built-workflow result (`simulatedNodes: [{nodeName, reason}]`). */
|
||||
function getSimulatedNodeNames(result: object): string[] {
|
||||
if (!('simulatedNodes' in result) || !Array.isArray(result.simulatedNodes)) return [];
|
||||
return result.simulatedNodes
|
||||
.map((entry) =>
|
||||
isRecord(entry) && typeof entry.nodeName === 'string' ? entry.nodeName : undefined,
|
||||
)
|
||||
.filter((name): name is string => name !== undefined);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { onBeforeUnmount, shallowRef, watch } from 'vue';
|
||||
import type { IPinData } from 'n8n-workflow';
|
||||
import { useAiSimulatedExecutionsStore } from '@/app/stores/aiSimulatedExecutions.store';
|
||||
import { useLogsStore } from '@/app/stores/logs.store';
|
||||
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
|
||||
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
|
||||
|
|
@ -45,6 +46,7 @@ export function useInstanceAiWorkflowPreviewExecution(
|
|||
const pushStore = usePushConnectionStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const logsStore = useLogsStore();
|
||||
const aiSimulatedExecutionsStore = useAiSimulatedExecutionsStore();
|
||||
// Thread runtime owns the user-run memory so it survives the preview unmounting
|
||||
// on a tab switch and is reachable here without prop-drilling (INS-611).
|
||||
const thread = useThread();
|
||||
|
|
@ -96,6 +98,14 @@ export function useInstanceAiWorkflowPreviewExecution(
|
|||
|
||||
async function showExecutionResult(executionResult: ExecutionResult | undefined) {
|
||||
if (!executionResult) return;
|
||||
// Record which node outputs of this agent execution are fabricated
|
||||
// fixtures, so the NDV can label them and guard the pin affordance.
|
||||
if (executionResult.simulatedNodeNames?.length) {
|
||||
aiSimulatedExecutionsStore.markSimulatedNodes(
|
||||
executionResult.executionId,
|
||||
executionResult.simulatedNodeNames,
|
||||
);
|
||||
}
|
||||
if (hasManualExecution()) return;
|
||||
if (supersededAgentExecutionId.value === executionResult.executionId) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { INodeUi } from '@/Interface';
|
|||
import type { IExecutionResponse } from '../executions.types';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import type { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
import { useAiSimulatedExecutionsStore } from '@/app/stores/aiSimulatedExecutions.store';
|
||||
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
|
||||
import { TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow';
|
||||
import { MODAL_CONFIRM } from '@/app/constants';
|
||||
|
|
@ -73,6 +74,60 @@ describe('useExecutionDebugging()', () => {
|
|||
executionDebugging = useExecutionDebugging();
|
||||
});
|
||||
|
||||
it('asks before pinning AI-simulated output and skips those nodes when declined', async () => {
|
||||
const mockExecution = {
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
SimulatedTrigger: [{ data: { main: [[{ json: { id: 'msg_1' } }]] } }],
|
||||
CleanTrigger: [{ data: { main: [[{ json: { real: true } }]] } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as IExecutionResponse;
|
||||
const workflowStore = mockedStore(useWorkflowsStore);
|
||||
mockWorkflowDocumentStore.allNodes = [
|
||||
{ name: 'SimulatedTrigger' },
|
||||
{ name: 'CleanTrigger' },
|
||||
] as INodeUi[];
|
||||
workflowStore.getExecution.mockResolvedValueOnce(mockExecution);
|
||||
const simulatedStore = mockedStore(useAiSimulatedExecutionsStore);
|
||||
simulatedStore.isSimulatedNodeOutput.mockImplementation(
|
||||
(executionId, nodeName) => executionId === '1' && nodeName === 'SimulatedTrigger',
|
||||
);
|
||||
mockConfirm.mockResolvedValueOnce('cancel');
|
||||
|
||||
await executionDebugging.applyExecutionData('1');
|
||||
|
||||
expect(mockWorkflowDocumentStore.pinNodeData).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorkflowDocumentStore.pinNodeData).toHaveBeenCalledWith('CleanTrigger', [
|
||||
{ json: { real: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins AI-simulated output when the adoption is confirmed', async () => {
|
||||
const mockExecution = {
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
SimulatedTrigger: [{ data: { main: [[{ json: { id: 'msg_1' } }]] } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as IExecutionResponse;
|
||||
const workflowStore = mockedStore(useWorkflowsStore);
|
||||
mockWorkflowDocumentStore.allNodes = [{ name: 'SimulatedTrigger' }] as INodeUi[];
|
||||
workflowStore.getExecution.mockResolvedValueOnce(mockExecution);
|
||||
const simulatedStore = mockedStore(useAiSimulatedExecutionsStore);
|
||||
simulatedStore.isSimulatedNodeOutput.mockReturnValue(true);
|
||||
|
||||
await executionDebugging.applyExecutionData('1');
|
||||
|
||||
expect(mockWorkflowDocumentStore.pinNodeData).toHaveBeenCalledWith('SimulatedTrigger', [
|
||||
{ json: { id: 'msg_1' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not throw when runData node is an empty array', async () => {
|
||||
const mockExecution = {
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { h, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useAiSimulatedDataGuard } from '@/app/composables/useAiSimulatedDataGuard';
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { EnterpriseEditionFeature, MODAL_CONFIRM, VIEWS } from '@/app/constants';
|
||||
|
|
@ -27,6 +28,7 @@ export const useExecutionDebugging = () => {
|
|||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const message = useMessage();
|
||||
const aiSimulatedDataGuard = useAiSimulatedDataGuard();
|
||||
const toast = useToast();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const workflowDocumentStore = injectWorkflowDocumentStore();
|
||||
|
|
@ -105,10 +107,20 @@ export const useExecutionDebugging = () => {
|
|||
);
|
||||
|
||||
// Pin data of all nodes which do not have a parent node
|
||||
const pinnableNodes = workflowNodes.filter(
|
||||
let pinnableNodes = workflowNodes.filter(
|
||||
(node: INodeUi) => !workflowDocumentStore.value.getParentNodes(node.name).length,
|
||||
);
|
||||
|
||||
// Data this execution recorded for AI-simulated nodes is fabricated —
|
||||
// copying it to the editor pins fake data, so it needs an explicit opt-in.
|
||||
const simulatedPinnableNodes = pinnableNodes.filter((node) =>
|
||||
aiSimulatedDataGuard.isSimulatedNodeOutput(executionId, node.name),
|
||||
);
|
||||
if (simulatedPinnableNodes.length > 0 && !(await aiSimulatedDataGuard.confirmAdoption())) {
|
||||
const simulatedNames = new Set(simulatedPinnableNodes.map((node) => node.name));
|
||||
pinnableNodes = pinnableNodes.filter((node) => !simulatedNames.has(node.name));
|
||||
}
|
||||
|
||||
let pinnings = 0;
|
||||
|
||||
pinnableNodes.forEach((node: INodeUi) => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ exports[`InputPanel > should render 1`] = `
|
|||
data-test-id="ndv-input-panel"
|
||||
data-v-5b5900d0=""
|
||||
>
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
<div
|
||||
class="header"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import { createComponentRenderer } from '@/__tests__/render';
|
|||
import { type MockedStore, mockedStore, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import RunData from './RunData.vue';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { SET_NODE_TYPE } from '@/app/constants';
|
||||
import { MODAL_CONFIRM, SET_NODE_TYPE } from '@/app/constants';
|
||||
import { useAiSimulatedExecutionsStore } from '@/app/stores/aiSimulatedExecutions.store';
|
||||
import type { INodeUi, IRunDataDisplayMode } from '@/Interface';
|
||||
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
|
||||
import type { NodePanelType } from '@/features/ndv/shared/ndv.types';
|
||||
|
|
@ -23,6 +24,7 @@ import {
|
|||
TRIMMED_TASK_DATA_CONNECTIONS_KEY,
|
||||
type ExecutionStatus,
|
||||
type INodeExecutionData,
|
||||
type IRunExecutionData,
|
||||
type ITaskData,
|
||||
type ITaskMetadata,
|
||||
type NodeHint,
|
||||
|
|
@ -39,13 +41,17 @@ import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionSt
|
|||
|
||||
const MOCK_EXECUTION_URL = 'execution.url/123';
|
||||
|
||||
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl, runWorkflow } = vi.hoisted(
|
||||
() => ({
|
||||
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl, runWorkflow, confirmMock } =
|
||||
vi.hoisted(() => ({
|
||||
trackOpeningRelatedExecution: vi.fn(),
|
||||
resolveRelatedExecutionUrl: vi.fn(),
|
||||
runWorkflow: vi.fn(),
|
||||
}),
|
||||
);
|
||||
confirmMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useMessage', () => ({
|
||||
useMessage: () => ({ confirm: confirmMock }),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => {
|
||||
return {
|
||||
|
|
@ -161,6 +167,116 @@ describe('RunData', () => {
|
|||
expect(getByText('Json data 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('AI-simulated output', () => {
|
||||
const simulatedItems = [{ json: { id: 'msg_1' } }];
|
||||
|
||||
beforeEach(() => {
|
||||
confirmMock.mockReset();
|
||||
});
|
||||
|
||||
it('labels output as simulated when the displayed execution marked the node', async () => {
|
||||
const { getByTestId, queryByTestId } = render({
|
||||
defaultRunItems: simulatedItems,
|
||||
displayMode: 'table',
|
||||
});
|
||||
|
||||
expect(queryByTestId('ndv-ai-simulated-data-callout')).not.toBeInTheDocument();
|
||||
|
||||
// The Instance AI preview registers provenance for the displayed execution.
|
||||
useAiSimulatedExecutionsStore().markSimulatedNodes('test-exec-id', ['Test Node']);
|
||||
|
||||
await waitFor(() => expect(getByTestId('ndv-ai-simulated-data-callout')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('does not label supplied historical executions, whose id is unknown', async () => {
|
||||
// Standalone hosts (agents log viewers) pass a different execution as a
|
||||
// prop; keying provenance off the active execution would mislabel it.
|
||||
const { queryByTestId } = render({
|
||||
displayMode: 'table',
|
||||
workflowExecutionProp: createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {
|
||||
'Test Node': [
|
||||
{
|
||||
startTime: Date.now(),
|
||||
executionIndex: 0,
|
||||
executionTime: 1,
|
||||
data: { main: [simulatedItems] },
|
||||
source: [null],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
useAiSimulatedExecutionsStore().markSimulatedNodes('test-exec-id', ['Test Node']);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(queryByTestId('ndv-ai-simulated-data-callout')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not label output of nodes the execution did not mark', async () => {
|
||||
const { queryByTestId } = render({
|
||||
defaultRunItems: simulatedItems,
|
||||
displayMode: 'table',
|
||||
});
|
||||
|
||||
useAiSimulatedExecutionsStore().markSimulatedNodes('test-exec-id', ['Other Node']);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(queryByTestId('ndv-ai-simulated-data-callout')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not pin simulated output when the confirmation is declined', async () => {
|
||||
confirmMock.mockResolvedValueOnce('cancel');
|
||||
const { getByTestId } = render({
|
||||
defaultRunItems: simulatedItems,
|
||||
displayMode: 'table',
|
||||
});
|
||||
useAiSimulatedExecutionsStore().markSimulatedNodes('test-exec-id', ['Test Node']);
|
||||
await waitFor(() => expect(getByTestId('ndv-ai-simulated-data-callout')).toBeInTheDocument());
|
||||
|
||||
await userEvent.click(getByTestId('ndv-pin-data'));
|
||||
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1);
|
||||
expect(workflowDocumentStore.pinnedDataByNodeName['Test Node']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pins simulated output after explicit confirmation', async () => {
|
||||
confirmMock.mockResolvedValueOnce(MODAL_CONFIRM);
|
||||
const { getByTestId } = render({
|
||||
defaultRunItems: simulatedItems,
|
||||
displayMode: 'table',
|
||||
});
|
||||
useAiSimulatedExecutionsStore().markSimulatedNodes('test-exec-id', ['Test Node']);
|
||||
await waitFor(() => expect(getByTestId('ndv-ai-simulated-data-callout')).toBeInTheDocument());
|
||||
|
||||
await userEvent.click(getByTestId('ndv-pin-data'));
|
||||
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() =>
|
||||
expect(workflowDocumentStore.pinnedDataByNodeName['Test Node']).toEqual(simulatedItems),
|
||||
);
|
||||
});
|
||||
|
||||
it('pins non-simulated output without asking for confirmation', async () => {
|
||||
const { getByTestId } = render({
|
||||
defaultRunItems: simulatedItems,
|
||||
displayMode: 'table',
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('ndv-pin-data'));
|
||||
|
||||
expect(confirmMock).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(workflowDocumentStore.pinnedDataByNodeName['Test Node']).toEqual(simulatedItems),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render only download buttons for PDFs', async () => {
|
||||
const { getByTestId, queryByTestId } = render({
|
||||
defaultRunItems: [
|
||||
|
|
@ -1488,6 +1604,7 @@ describe('RunData', () => {
|
|||
executionStatus,
|
||||
nodeTypeHints,
|
||||
withRunData = true,
|
||||
workflowExecutionProp,
|
||||
}: {
|
||||
defaultRunItems?: INodeExecutionData[];
|
||||
workflowId?: string;
|
||||
|
|
@ -1502,6 +1619,8 @@ describe('RunData', () => {
|
|||
executionStatus?: ExecutionStatus;
|
||||
nodeTypeHints?: NodeHint[];
|
||||
withRunData?: boolean;
|
||||
/** Supplied historical execution data, as standalone hosts pass it. */
|
||||
workflowExecutionProp?: IRunExecutionData;
|
||||
lastSuccessfulExecution?: {
|
||||
id: string;
|
||||
finished: boolean;
|
||||
|
|
@ -1602,13 +1721,17 @@ describe('RunData', () => {
|
|||
nodes: workflowNodes,
|
||||
}),
|
||||
displayMode,
|
||||
...(workflowExecutionProp ? { workflowExecution: workflowExecutionProp } : {}),
|
||||
},
|
||||
global: {
|
||||
provide: {
|
||||
[WorkflowIdKey as unknown as string]: computed(() => workflowId ?? 'test-workflow'),
|
||||
},
|
||||
stubs: {
|
||||
RunDataPinButton: { template: '<button data-test-id="ndv-pin-data"></button>' },
|
||||
RunDataPinButton: {
|
||||
template:
|
||||
'<button data-test-id="ndv-pin-data" @click="$emit(\'togglePinData\')"></button>',
|
||||
},
|
||||
NodeErrorView: { template: '<div data-test-id="node-error-view"></div>' },
|
||||
VirtualSchema: {
|
||||
template: `
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import { useToast } from '@n8n/composables/useToast';
|
|||
import { dataPinningEventBus } from '@/app/event-bus';
|
||||
import { ndvEventBus } from '@/features/ndv/shared/ndv.eventBus';
|
||||
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
|
||||
import { useAiSimulatedDataGuard } from '@/app/composables/useAiSimulatedDataGuard';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
|
||||
|
|
@ -244,6 +245,7 @@ const rootStore = useRootStore();
|
|||
const schemaPreviewStore = useSchemaPreviewStore();
|
||||
|
||||
const toast = useToast();
|
||||
const aiSimulatedDataGuard = useAiSimulatedDataGuard();
|
||||
const route = useRoute();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const externalHooks = useExternalHooks();
|
||||
|
|
@ -380,6 +382,24 @@ const hasNodeRun = computed(() =>
|
|||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The displayed output of this node was simulated (fabricated fixture data)
|
||||
* by the AI Assistant during workflow verification — label it and guard the
|
||||
* pin affordance so it isn't adopted as if it were real.
|
||||
*
|
||||
* Only meaningful when the pane displays the active execution: a supplied
|
||||
* `workflowExecution` prop is a different execution whose id we don't know,
|
||||
* so checking the active id against it could mislabel real data.
|
||||
*/
|
||||
const isAiSimulatedOutput = computed(
|
||||
() =>
|
||||
!isPaneTypeInput.value &&
|
||||
props.workflowExecution === undefined &&
|
||||
hasNodeRun.value &&
|
||||
!pinnedData.hasData.value &&
|
||||
aiSimulatedDataGuard.isSimulatedNodeOutput(currentExecution.value?.id, node.value?.name),
|
||||
);
|
||||
|
||||
const isArtificialRecoveredEventItem = computed(
|
||||
() => rawInputData.value?.[0]?.json?.isArtificialRecoveredEventItem,
|
||||
);
|
||||
|
|
@ -1022,7 +1042,7 @@ function onClickCancelEdit() {
|
|||
onExitEditMode({ type: 'cancel' });
|
||||
}
|
||||
|
||||
function onClickSaveEdit() {
|
||||
async function onClickSaveEdit() {
|
||||
if (!node.value) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1031,6 +1051,11 @@ function onClickSaveEdit() {
|
|||
|
||||
toast.clearAllStickyNotifications();
|
||||
|
||||
// Saving output edits pins the result — same adoption boundary as the pin icon.
|
||||
if (!(await confirmPinningAiSimulatedData())) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const clearedValue = clearJsonKey(value) as INodeExecutionData[];
|
||||
try {
|
||||
|
|
@ -1059,6 +1084,15 @@ function onExitEditMode({ type }: { type: 'save' | 'cancel' }) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinning AI-simulated data needs an explicit opt-in: the fabricated fixture
|
||||
* would otherwise silently become the node's "real" data in every test run.
|
||||
*/
|
||||
async function confirmPinningAiSimulatedData(): Promise<boolean> {
|
||||
if (!isAiSimulatedOutput.value) return true;
|
||||
return await aiSimulatedDataGuard.confirmAdoption();
|
||||
}
|
||||
|
||||
async function onTogglePinData({ source }: { source: PinDataSource | UnpinDataSource }) {
|
||||
if (!node.value) {
|
||||
return;
|
||||
|
|
@ -1085,6 +1119,10 @@ async function onTogglePinData({ source }: { source: PinDataSource | UnpinDataSo
|
|||
return;
|
||||
}
|
||||
|
||||
if (!(await confirmPinningAiSimulatedData())) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
pinnedData.setData(rawInputData.value, 'pin-icon-click');
|
||||
} catch (error) {
|
||||
|
|
@ -1510,6 +1548,16 @@ defineExpose({ enterEditMode });
|
|||
</template>
|
||||
</N8nCallout>
|
||||
|
||||
<N8nCallout
|
||||
v-if="isAiSimulatedOutput && !editMode.enabled"
|
||||
theme="warning"
|
||||
icon="triangle-alert"
|
||||
:class="$style.pinnedDataCallout"
|
||||
data-test-id="ndv-ai-simulated-data-callout"
|
||||
>
|
||||
{{ i18n.baseText('runData.aiSimulatedData.callout') }}
|
||||
</N8nCallout>
|
||||
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.title">
|
||||
<slot name="header"></slot>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
|
||||
import { useExternalHooks } from '@/app/composables/useExternalHooks';
|
||||
import { useKeybindings } from '@/app/composables/useKeybindings';
|
||||
import { useAiSimulatedDataGuard } from '@/app/composables/useAiSimulatedDataGuard';
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import { useNdvLayout } from '../../panel/composables/useNdvLayout';
|
||||
import { useNodeDocsUrl } from '@/app/composables/useNodeDocsUrl';
|
||||
|
|
@ -87,6 +88,7 @@ const telemetry = useTelemetry();
|
|||
const telemetryContext = useTelemetryContext({ view_shown: 'ndv' });
|
||||
const i18n = useI18n();
|
||||
const message = useMessage();
|
||||
const aiSimulatedDataGuard = useAiSimulatedDataGuard();
|
||||
const { APP_Z_INDEXES } = useStyles();
|
||||
|
||||
const settingsEventBus = createEventBus();
|
||||
|
|
@ -483,10 +485,17 @@ const close = async () => {
|
|||
}
|
||||
|
||||
if (outputPanelEditMode.value.enabled && activeNode.value) {
|
||||
// Saving edits made on AI-simulated output adopts fabricated sample data
|
||||
// as pins — say so in the dialog instead of stacking a second confirm.
|
||||
const editsSimulatedOutput = aiSimulatedDataGuard.isSimulatedNodeOutput(
|
||||
workflowExecutionStateStore.value.activeExecution?.id,
|
||||
activeNode.value.name,
|
||||
);
|
||||
const shouldPinDataBeforeClosing = await message.confirm(
|
||||
'',
|
||||
editsSimulatedOutput ? i18n.baseText('ndv.pinData.aiSimulated.confirm.description') : '',
|
||||
i18n.baseText('ndv.pinData.beforeClosing.title'),
|
||||
{
|
||||
...(editsSimulatedOutput ? { type: 'warning' as const } : {}),
|
||||
confirmButtonText: i18n.baseText('ndv.pinData.beforeClosing.confirm'),
|
||||
cancelButtonText: i18n.baseText('ndv.pinData.beforeClosing.cancel'),
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user