From fccaace7f716e1389af88122565d33d5a0706890 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Fri, 10 Jul 2026 11:12:33 +0200 Subject: [PATCH] fix(core): Detect stale Instance AI workflow saves (no-changelog) (#33711) Co-authored-by: Cursor --- .../errors/workflow-save-conflict.error.ts | 13 ++ packages/@n8n/instance-ai/src/index.ts | 1 + .../tools/__tests__/workflows.tool.test.ts | 144 ++++++++++++++++++ .../tools/evals/__tests__/evals.tool.test.ts | 6 +- .../instance-ai/src/tools/evals/evals.tool.ts | 13 +- .../instance-ai/src/tools/workflows.tool.ts | 14 ++ .../apply-workflow-credentials.tool.test.ts | 6 +- .../__tests__/build-workflow.tool.test.ts | 84 +++++++++- .../__tests__/setup-workflow.service.test.ts | 54 +++++-- .../workflow-build-remediation.test.ts | 15 ++ .../__tests__/workflow-file-bindings.test.ts | 113 ++++++++++++++ .../apply-workflow-credentials.tool.ts | 7 +- .../tools/workflows/build-workflow.tool.ts | 63 +++++++- .../tools/workflows/setup-workflow.service.ts | 19 ++- .../workflows/workflow-build-remediation.ts | 13 ++ .../workflows/workflow-build-telemetry.ts | 3 +- .../tools/workflows/workflow-file-bindings.ts | 66 ++++++++ packages/@n8n/instance-ai/src/types.ts | 4 +- .../instance-ai.adapter.service.test.ts | 85 ++++++++--- .../instance-ai.adapter.service.ts | 24 ++- 20 files changed, 692 insertions(+), 55 deletions(-) create mode 100644 packages/@n8n/instance-ai/src/errors/workflow-save-conflict.error.ts create mode 100644 packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-build-remediation.test.ts diff --git a/packages/@n8n/instance-ai/src/errors/workflow-save-conflict.error.ts b/packages/@n8n/instance-ai/src/errors/workflow-save-conflict.error.ts new file mode 100644 index 00000000000..fc4b48692d7 --- /dev/null +++ b/packages/@n8n/instance-ai/src/errors/workflow-save-conflict.error.ts @@ -0,0 +1,13 @@ +import { OperationalError } from 'n8n-workflow'; + +/** + * Thrown when an Instance AI workflow save loses optimistic-concurrency: + * the workflow was modified outside the builder's last-known snapshot. + */ +export class WorkflowSaveConflictError extends OperationalError { + constructor(workflowId: string) { + super(`Workflow ${workflowId} was modified outside this conversation since the last save.`, { + level: 'warning', + }); + } +} diff --git a/packages/@n8n/instance-ai/src/index.ts b/packages/@n8n/instance-ai/src/index.ts index d1064802ec0..6f8d97bcb6e 100644 --- a/packages/@n8n/instance-ai/src/index.ts +++ b/packages/@n8n/instance-ai/src/index.ts @@ -174,6 +174,7 @@ const loadValidateAttachments = lazyModule( ); export { MAX_STEPS } from './constants/max-steps'; +export { WorkflowSaveConflictError } from './errors/workflow-save-conflict.error'; export { LEGACY_PLANNED_TASK_KINDS, PLANNED_TASK_KINDS, diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts index 7843fa5c502..0a27a9e8545 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts @@ -10,6 +10,10 @@ import { buildCompletedReport, } from '../workflows/setup-workflow.service'; import { STRUCTURE_ONLY_NOTE } from '../workflows/summarize-workflow'; +import { + getWorkflowSourceFileBinding, + saveWorkflowSourceFileBinding, +} from '../workflows/workflow-file-bindings'; import { createWorkflowsTool, type WorkflowAction } from '../workflows.tool'; // Mock the setup-workflow.service module to avoid pulling in heavy dependencies @@ -643,6 +647,118 @@ describe('workflows tool', () => { }); }); + describe('workflow source binding refresh', () => { + it('refreshes bound checksum after current-version get-as-code', async () => { + const context = createMockContext(); + (context.workflowService.get as Mock).mockResolvedValue({ + id: 'wf1', + name: 'Test WF', + versionId: 'v-current', + checksum: 'checksum-current', + activeVersionId: null, + isArchived: false, + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + nodes: [], + connections: {}, + }); + + await saveWorkflowSourceFileBinding(context, { + filePath: 'src/workflows/main.workflow.ts', + workflowId: 'wf1', + workflowVersionId: 'v-stale', + workflowChecksum: 'checksum-stale', + }); + + const tool = createWorkflowsTool(context, 'full'); + await executeTool(tool, { action: 'get-as-code', workflowId: 'wf1' }, {} as never); + + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/main.workflow.ts'), + ).resolves.toMatchObject({ + workflowId: 'wf1', + workflowVersionId: 'v-current', + workflowChecksum: 'checksum-current', + }); + }); + + it('does not refresh bound checksum for historical get-as-code reads', async () => { + const context = createMockContext(); + + await saveWorkflowSourceFileBinding(context, { + filePath: 'src/workflows/main.workflow.ts', + workflowId: 'wf1', + workflowVersionId: 'v-stale', + workflowChecksum: 'checksum-stale', + }); + + const tool = createWorkflowsTool(context, 'full'); + await executeTool( + tool, + { action: 'get-as-code', workflowId: 'wf1', versionId: 'v7' }, + {} as never, + ); + + expect(context.workflowService.get).not.toHaveBeenCalled(); + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/main.workflow.ts'), + ).resolves.toMatchObject({ + workflowId: 'wf1', + workflowVersionId: 'v-stale', + workflowChecksum: 'checksum-stale', + }); + }); + + it('refreshes bound checksum after update', async () => { + const context = createMockContext({ + permissions: { updateWorkflow: 'always_allow' }, + }); + (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({ + id: 'wf1', + versionId: 'v-updated', + checksum: 'checksum-updated', + }); + (context.workflowService.get as Mock).mockResolvedValue({ + id: 'wf1', + name: 'Test WF', + versionId: 'v-updated', + checksum: 'checksum-updated', + activeVersionId: null, + isArchived: false, + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + nodes: [], + connections: {}, + }); + + await saveWorkflowSourceFileBinding(context, { + filePath: 'src/workflows/main.workflow.ts', + workflowId: 'wf1', + workflowVersionId: 'v-stale', + workflowChecksum: 'checksum-stale', + }); + + const tool = createWorkflowsTool(context, 'full'); + await executeTool( + tool, + { + action: 'update', + workflowId: 'wf1', + workflow: { name: 'Updated WF', nodes: [], connections: {} }, + }, + { resumeData: { approved: true } } as never, + ); + + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/main.workflow.ts'), + ).resolves.toMatchObject({ + workflowId: 'wf1', + workflowVersionId: 'v-updated', + workflowChecksum: 'checksum-updated', + }); + }); + }); + describe('delete action', () => { it('should return denied when permission is blocked', async () => { const context = createMockContext({ @@ -921,6 +1037,7 @@ describe('workflows tool', () => { id: workflowId, name: workflowId, versionId: `${workflowId}-draft`, + checksum: `${workflowId}-checksum`, activeVersionId: workflowId === 'sub-a' ? 'sub-a-previous' : null, isArchived: false, createdAt: '2024-01-01', @@ -933,6 +1050,19 @@ describe('workflows tool', () => { return { activeVersionId: `${workflowId}-active` }; }); + await saveWorkflowSourceFileBinding(context, { + filePath: 'src/workflows/sub-a.workflow.ts', + workflowId: 'sub-a', + workflowVersionId: 'sub-a-previous', + workflowChecksum: 'sub-a-previous-checksum', + }); + await saveWorkflowSourceFileBinding(context, { + filePath: 'src/workflows/sub-b.workflow.ts', + workflowId: 'sub-b', + workflowVersionId: 'sub-b-previous', + workflowChecksum: 'sub-b-previous-checksum', + }); + const tool = createWorkflowsTool(context, 'full'); const result = await executeTool(tool, { action: 'publish', workflowId: 'wf1' }, { resumeData: { approved: true }, @@ -952,6 +1082,20 @@ describe('workflows tool', () => { error: 'Main publish failed', rolledBackWorkflowIds: ['sub-b', 'sub-a'], }); + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/sub-a.workflow.ts'), + ).resolves.toMatchObject({ + workflowId: 'sub-a', + workflowVersionId: 'sub-a-draft', + workflowChecksum: 'sub-a-checksum', + }); + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/sub-b.workflow.ts'), + ).resolves.toMatchObject({ + workflowId: 'sub-b', + workflowVersionId: 'sub-b-draft', + workflowChecksum: 'sub-b-checksum', + }); }); it('should suspend for confirmation using the looked-up workflow name', async () => { diff --git a/packages/@n8n/instance-ai/src/tools/evals/__tests__/evals.tool.test.ts b/packages/@n8n/instance-ai/src/tools/evals/__tests__/evals.tool.test.ts index 1c8437ddd6b..e65dac5c7a5 100644 --- a/packages/@n8n/instance-ai/src/tools/evals/__tests__/evals.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/evals/__tests__/evals.tool.test.ts @@ -219,7 +219,11 @@ function makeCtx( userId: 'u1', workflowService: { getAsWorkflowJSON: vi.fn().mockResolvedValue(wf), - updateFromWorkflowJSON: vi.fn().mockResolvedValue(undefined), + updateFromWorkflowJSON: vi.fn().mockResolvedValue({ + id: 'w1', + versionId: 'v-1', + checksum: 'checksum-1', + }), }, dataTableService: { create: vi.fn().mockResolvedValue({ diff --git a/packages/@n8n/instance-ai/src/tools/evals/evals.tool.ts b/packages/@n8n/instance-ai/src/tools/evals/evals.tool.ts index 2c6ef361ad7..4fdf9eb5151 100644 --- a/packages/@n8n/instance-ai/src/tools/evals/evals.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/evals/evals.tool.ts @@ -26,6 +26,7 @@ import { } from './metric-catalog'; import { sanitizeInputSchema } from '../../agent/sanitize-mcp-schemas'; import type { InstanceAiContext } from '../../types'; +import { refreshWorkflowSourceFileBindingFromSave } from '../workflows/workflow-file-bindings'; // ── Action input schemas ─────────────────────────────────────────────────── @@ -528,8 +529,16 @@ async function executePropose(context: InstanceAiContext, input: z.infer await Promise.resolve({ id: 'wf-1', versionId: 'v-1' }), + async () => + await Promise.resolve({ id: 'wf-1', versionId: 'v-1', checksum: 'checksum-create' }), ), updateFromWorkflowJSON: vi.fn( async (workflowId: string) => - await Promise.resolve({ id: workflowId, versionId: 'v-next' }), + await Promise.resolve({ + id: workflowId, + versionId: 'v-next', + checksum: 'checksum-update', + }), + ), + get: vi.fn( + async (workflowId: string) => + await Promise.resolve({ + id: workflowId, + versionId: 'v-current', + checksum: 'checksum-current', + }), ), getAsWorkflowJSON: vi.fn(async () => await Promise.resolve({ name: 'Target workflow' })), clearAiTemporary: vi.fn(async () => await Promise.resolve()), @@ -430,12 +444,22 @@ describe('createBuildWorkflowTool', () => { expect(first).toMatchObject({ success: true, workflowId: 'wf-bound' }); expect(second).toMatchObject({ success: true, workflowId: 'wf-bound' }); expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledTimes(2); - expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledWith( + expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenNthCalledWith( + 1, 'wf-bound', expect.any(Object), - undefined, + { expectedChecksum: 'checksum-current' }, + ); + expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenNthCalledWith( + 2, + 'wf-bound', + expect.any(Object), + { expectedChecksum: 'checksum-update' }, ); expect(context.workflowService.createFromWorkflowJSON).not.toHaveBeenCalled(); + await expect(getWorkflowSourceFileBinding(context, filePath)).resolves.toMatchObject({ + workflowChecksum: 'checksum-update', + }); expect(trackTelemetry).toHaveBeenCalledWith( 'instance_ai_workflow_source_build', expect.objectContaining({ @@ -450,6 +474,46 @@ describe('createBuildWorkflowTool', () => { ); }); + it('returns conflict remediation when the workflow changed outside the conversation', async () => { + const { context, filePath, trackTelemetry } = makeContext({ source: 'workflow source' }); + const tool = createBuildWorkflowTool(context); + + await executeTool(tool, { + filePath, + workflowId: 'wf-bound', + }); + + vi.mocked(context.workflowService.updateFromWorkflowJSON).mockRejectedValueOnce( + new WorkflowSaveConflictError('wf-bound'), + ); + + const result = await executeTool(tool, { filePath }); + + expect(result).toMatchObject({ + success: false, + filePath, + workflowId: 'wf-bound', + remediation: { + category: 'code_fixable', + shouldEdit: true, + reason: 'workflow_modified_externally', + }, + }); + expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenLastCalledWith( + 'wf-bound', + expect.any(Object), + { expectedChecksum: 'checksum-update' }, + ); + expect(trackTelemetry).toHaveBeenCalledWith( + 'instance_ai_workflow_source_build', + expect.objectContaining({ + result: 'failure', + stage: 'conflict', + target_workflow_id: 'wf-bound', + }), + ); + }); + it('preserves setup-applied placeholder values before updating an existing workflow', async () => { const rebuiltWorkflow = { name: 'Daily Berlin Rain Alert', @@ -524,6 +588,14 @@ describe('createBuildWorkflowTool', () => { async (workflowId: string) => await Promise.resolve({ id: workflowId, versionId: 'v-next' }), ), + get: vi.fn( + async (workflowId: string) => + await Promise.resolve({ + id: workflowId, + versionId: 'v-current', + checksum: 'checksum-current', + }), + ), getAsWorkflowJSON: vi.fn(async () => await Promise.resolve(existingWorkflow)), clearAiTemporary: vi.fn(async () => await Promise.resolve()), } as unknown as InstanceAiContext['workflowService'], @@ -538,7 +610,7 @@ describe('createBuildWorkflowTool', () => { expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledWith( 'wf-existing', expect.any(Object), - undefined, + { expectedChecksum: 'checksum-current' }, ); const savedWorkflow = vi.mocked(context.workflowService.updateFromWorkflowJSON).mock .calls[0]?.[1]; @@ -601,7 +673,7 @@ describe('createBuildWorkflowTool', () => { expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledWith( 'wf-existing', workflowJson, - undefined, + { expectedChecksum: 'checksum-current' }, ); expect(context.workflowService.createFromWorkflowJSON).not.toHaveBeenCalled(); }); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts index 7a06c456666..3db2755e1a8 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts @@ -27,7 +27,11 @@ function createMockContext(overrides?: Partial): InstanceAiCo getWorkflowHead: vi.fn(), getWorkflowSnapshot: vi.fn(), createFromWorkflowJSON: vi.fn(), - updateFromWorkflowJSON: vi.fn(), + updateFromWorkflowJSON: vi.fn().mockResolvedValue({ + id: 'wf-1', + versionId: 'v-next', + checksum: 'checksum-next', + }), archive: vi.fn(), unarchive: vi.fn(), publish: vi.fn(), @@ -1037,7 +1041,11 @@ describe('applyNodeChanges', () => { (context.credentialService.get as Mock).mockImplementation( async (id: string) => await Promise.resolve({ id, name: `Cred ${id}` }), ); - (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined); + (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({ + id: 'wf-1', + versionId: 'v-1', + checksum: 'checksum-1', + }); const result = await applyNodeChanges( context, @@ -1057,7 +1065,11 @@ describe('applyNodeChanges', () => { const wfJson = makeWorkflowJSON([makeNode({ name: 'Slack', id: 'n1' })]); (context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson); (context.credentialService.get as Mock).mockResolvedValue(undefined); - (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined); + (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({ + id: 'wf-1', + versionId: 'v-1', + checksum: 'checksum-1', + }); const result = await applyNodeChanges(context, 'wf-1', { Slack: { slackApi: 'nonexistent' }, @@ -1106,7 +1118,11 @@ describe('applyNodeChanges', () => { }, ], }); - (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined); + (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({ + id: 'wf-1', + versionId: 'v-1', + checksum: 'checksum-1', + }); await applyNodeChanges(context, 'wf-1'); @@ -1135,7 +1151,11 @@ describe('applyNodeChanges', () => { id: 'cred-1', name: 'My Header Auth', }); - (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined); + (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({ + id: 'wf-1', + versionId: 'v-1', + checksum: 'checksum-1', + }); await applyNodeChanges(context, 'wf-1', { 'HTTP Request': { httpHeaderAuth: 'cred-1' }, @@ -1155,7 +1175,11 @@ describe('applyNodeChanges', () => { const node = makeNode({ name: 'Gemini', type: 'n8n-nodes-base.lmChatGoogleGemini' }); const wfJson = makeWorkflowJSON([node]); (context.workflowService.getAsWorkflowJSON as Mock).mockResolvedValue(wfJson); - (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined); + (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({ + id: 'wf-1', + versionId: 'v-1', + checksum: 'checksum-1', + }); const result = await applyNodeChanges(context, 'wf-1', { Gemini: { googlePalmApi: AI_GATEWAY_MANAGED_TAG }, @@ -1184,7 +1208,11 @@ describe('applyNodeChanges', () => { group: [], credentials: [], }); - (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined); + (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({ + id: 'wf-1', + versionId: 'v-1', + checksum: 'checksum-1', + }); const result = await applyNodeChanges(context, 'wf-1', undefined, { 'HTTP Request': { url: 'https://example.com/api' }, @@ -1220,7 +1248,11 @@ describe('applyNodeChanges', () => { group: [], credentials: [], }); - (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined); + (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({ + id: 'wf-1', + versionId: 'v-1', + checksum: 'checksum-1', + }); const result = await applyNodeChanges(context, 'wf-1', undefined, { channelId: { __rl: true, mode: 'name', value: '#berlin-weather-rain' }, @@ -1260,7 +1292,11 @@ describe('applyNodeChanges', () => { group: [], credentials: [], }); - (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue(undefined); + (context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({ + id: 'wf-1', + versionId: 'v-1', + checksum: 'checksum-1', + }); await applyNodeChanges(context, 'wf-1'); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-build-remediation.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-build-remediation.test.ts new file mode 100644 index 00000000000..55ce991083a --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-build-remediation.test.ts @@ -0,0 +1,15 @@ +import { WorkflowSaveConflictError } from '../../../errors/workflow-save-conflict.error'; +import { createSaveFailureRemediation } from '../workflow-build-remediation'; + +describe('createSaveFailureRemediation', () => { + it('returns workflow_modified_externally remediation for save conflicts', () => { + const remediation = createSaveFailureRemediation(new WorkflowSaveConflictError('wf-1'), true); + + expect(remediation).toMatchObject({ + category: 'code_fixable', + shouldEdit: true, + reason: 'workflow_modified_externally', + }); + expect(remediation.guidance).toContain('get-as-code'); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-file-bindings.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-file-bindings.test.ts index 55e3bf304e6..0733d35b13a 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-file-bindings.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/workflow-file-bindings.test.ts @@ -1,6 +1,9 @@ import type { InstanceAiContext } from '../../../types'; import { + bindSourceFileToExistingWorkflow, getWorkflowSourceFileBinding, + refreshWorkflowSourceFileBindingFromSave, + refreshWorkflowSourceFileBindingFromWorkflow, saveWorkflowSourceFileBinding, } from '../workflow-file-bindings'; @@ -34,4 +37,114 @@ describe('workflow source file bindings', () => { workflowId: 'wf-1', }); }); + + it('refreshes checksum and version for bindings tied to a workflow id', async () => { + const context = { + logger: { debug: vi.fn(), warn: vi.fn() }, + } as unknown as InstanceAiContext; + + await saveWorkflowSourceFileBinding(context, { + filePath: 'src/workflows/main.workflow.ts', + workflowId: 'wf-1', + workflowVersionId: 'v-old', + workflowChecksum: 'checksum-old', + }); + + await refreshWorkflowSourceFileBindingFromSave(context, 'wf-1', { + versionId: 'v-new', + checksum: 'checksum-new', + }); + + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/main.workflow.ts'), + ).resolves.toMatchObject({ + workflowId: 'wf-1', + workflowVersionId: 'v-new', + workflowChecksum: 'checksum-new', + }); + }); + + it('clears workflowChecksum when refresh receives no checksum', async () => { + const context = { + logger: { debug: vi.fn(), warn: vi.fn() }, + } as unknown as InstanceAiContext; + + await saveWorkflowSourceFileBinding(context, { + filePath: 'src/workflows/main.workflow.ts', + workflowId: 'wf-1', + workflowVersionId: 'v-old', + workflowChecksum: 'checksum-old', + }); + + await refreshWorkflowSourceFileBindingFromSave(context, 'wf-1', { + versionId: 'v-new', + }); + + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/main.workflow.ts'), + ).resolves.toMatchObject({ + workflowId: 'wf-1', + workflowVersionId: 'v-new', + }); + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/main.workflow.ts'), + ).resolves.not.toHaveProperty('workflowChecksum'); + }); + + it('seeds version and checksum when binding a source file to an existing workflow', async () => { + const context = { + workflowService: { + get: vi.fn().mockResolvedValue({ + id: 'wf-1', + versionId: 'v-current', + checksum: 'checksum-current', + }), + }, + logger: { debug: vi.fn(), warn: vi.fn() }, + } as unknown as InstanceAiContext; + + await bindSourceFileToExistingWorkflow( + context, + { filePath: 'src/workflows/main.workflow.ts' }, + 'wf-1', + ); + + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/main.workflow.ts'), + ).resolves.toMatchObject({ + workflowId: 'wf-1', + workflowVersionId: 'v-current', + workflowChecksum: 'checksum-current', + }); + }); + + it('refreshes bindings from the current workflow record', async () => { + const context = { + workflowService: { + get: vi.fn().mockResolvedValue({ + id: 'wf-1', + versionId: 'v-current', + checksum: 'checksum-current', + }), + }, + logger: { debug: vi.fn(), warn: vi.fn() }, + } as unknown as InstanceAiContext; + + await saveWorkflowSourceFileBinding(context, { + filePath: 'src/workflows/main.workflow.ts', + workflowId: 'wf-1', + workflowVersionId: 'v-stale', + workflowChecksum: 'checksum-stale', + }); + + await refreshWorkflowSourceFileBindingFromWorkflow(context, 'wf-1'); + + await expect( + getWorkflowSourceFileBinding(context, 'src/workflows/main.workflow.ts'), + ).resolves.toMatchObject({ + workflowId: 'wf-1', + workflowVersionId: 'v-current', + workflowChecksum: 'checksum-current', + }); + }); }); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/apply-workflow-credentials.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows/apply-workflow-credentials.tool.ts index a6ef5c5441c..3802ec18260 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/apply-workflow-credentials.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/apply-workflow-credentials.tool.ts @@ -12,6 +12,7 @@ import { z } from 'zod'; import { assignCredentialToNode, resolveCredentialForApply } from './credential-utils'; import { reconcileSimulationPlan } from './reconcile-simulation-plan'; import { buildCredentialMap } from './resolve-credentials'; +import { refreshWorkflowSourceFileBindingFromSave } from './workflow-file-bindings'; import type { OrchestrationContext } from '../../types'; export const applyWorkflowCredentialsInputSchema = z.object({ @@ -84,7 +85,11 @@ export function createApplyWorkflowCredentialsTool(context: OrchestrationContext // Save the workflow with applied credentials try { - await workflowService.updateFromWorkflowJSON(input.workflowId, json); + const saved = await workflowService.updateFromWorkflowJSON(input.workflowId, json); + await refreshWorkflowSourceFileBindingFromSave(context.domainContext, input.workflowId, { + versionId: saved.versionId, + checksum: saved.checksum, + }); } catch (error) { return { success: false, diff --git a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts index aa50b80624c..25fea6abdf3 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts @@ -28,6 +28,7 @@ import { createCodeFixableRemediation, createSaveFailureRemediation, createSourceCompileRemediation, + createWorkflowModifiedExternallyRemediation, } from './workflow-build-remediation'; import { promoteMainWorkflow, @@ -37,6 +38,7 @@ import { import { withDeterministicRouting } from './workflow-build-routing'; import { trackWorkflowSourceBuild } from './workflow-build-telemetry'; import { + bindSourceFileToExistingWorkflow, getWorkflowSourceFileBinding, hashWorkflowSource, normalizeWorkflowSourceFilePath, @@ -51,15 +53,16 @@ import { preserveExistingSetupValues, } from './workflow-json-utils'; import { compileWorkflowSource } from './workflow-source-compiler'; -import { emitTraceOnlyChildRun } from '../../tracing/langsmith-tracing'; -import { COMPILED_WORKFLOW_TRACE_RUN_NAME } from '../tool-ids'; import { partitionWarnings, type ValidationWarning } from './workflow-validation-warnings'; +import { WorkflowSaveConflictError } from '../../errors/workflow-save-conflict.error'; import { INSTANCE_AI_SKILLS_DIR } from '../../skills/runtime-skills'; +import { emitTraceOnlyChildRun } from '../../tracing/langsmith-tracing'; import type { InstanceAiContext } from '../../types'; import { BuildFailureTracker } from '../../workflow-builder/build-failure-tracker'; import { createRemediation } from '../../workflow-loop/remediation'; import { remediationMetadataSchema } from '../../workflow-loop/workflow-loop-state'; import { writeWorkspaceFile } from '../../workspace/workspace-files'; +import { COMPILED_WORKFLOW_TRACE_RUN_NAME } from '../tool-ids'; /** Over this serialized length only a `truncated` marker is emitted; the seed * consumer falls back to source replay. */ @@ -336,10 +339,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { } if (input.workflowId && !binding.workflowId) { - binding = await saveWorkflowSourceFileBinding(context, { - ...binding, - workflowId: input.workflowId, - }); + binding = await bindSourceFileToExistingWorkflow(context, binding, input.workflowId); } const targetWorkflowId = binding.workflowId; @@ -732,7 +732,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { ); const hasPlaceholders = (json.nodes ?? []).some((n) => hasPlaceholderDeep(n.parameters)); const createSuccessResponse = async ( - saved: { id: string; versionId: string }, + saved: { id: string; versionId: string; checksum?: string }, operation: 'create' | 'update', ) => { const setupRequests = await analyzeWorkflow(context, saved.id); @@ -751,6 +751,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { ...binding, workflowId: saved.id, workflowVersionId: saved.versionId, + ...(saved.checksum ? { workflowChecksum: saved.checksum } : {}), sourceHash, }); // Trace-only compiled-JSON event for eval seed reconstruction — never part @@ -867,10 +868,18 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { }; if (targetWorkflowId) { + const updateOptions = projectId + ? { + projectId, + ...(binding.workflowChecksum ? { expectedChecksum: binding.workflowChecksum } : {}), + } + : binding.workflowChecksum + ? { expectedChecksum: binding.workflowChecksum } + : undefined; const updated = await context.workflowService.updateFromWorkflowJSON( targetWorkflowId, json, - projectId ? { projectId } : undefined, + updateOptions, ); return await createSuccessResponse(updated, 'update'); } @@ -883,6 +892,44 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { return await createSuccessResponse(created, 'create'); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + + if (error instanceof WorkflowSaveConflictError) { + const remediation = createWorkflowModifiedExternallyRemediation(); + binding = await markSourceBuildFailed(context, binding, sourceHash); + await reportFailedWorkflowBuildOutcome(context, { + targetWorkflowId, + sourceFilePath: filePath, + workItemId: resolvedWorkItemId, + taskId: resolvedTaskId, + plannedTaskId, + owner, + remediation, + errors: [message], + summary: 'Workflow save conflict — the workflow changed outside this conversation.', + storeOnRunContext: !isAuxiliarySupportingWorkflow, + }); + trackWorkflowSourceBuild(context, { + result: 'failure', + stage: 'conflict', + binding, + targetWorkflowId, + saveOperation: 'update', + isSupportingWorkflow, + isAuxiliarySupportingWorkflow, + remediation, + errorCount: 1, + }); + return { + success: false, + ...sourceResponseBase(binding), + workflowId: targetWorkflowId, + workflowName: json.name || undefined, + workItemId: resolvedWorkItemId, + errors: [message], + remediation, + }; + } + const remediation = createSaveFailureRemediation(error, Boolean(binding.workflowId)); binding = await markSourceBuildFailed(context, binding, sourceHash); await reportFailedWorkflowBuildOutcome(context, { diff --git a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts index d3448c5f688..338f3c826e0 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts @@ -21,6 +21,7 @@ import { } from './credential-utils'; import { coerceWrongKindListModeParams } from './detect-wrong-kind-locator'; import type { SetupRequest } from './setup-workflow.schema'; +import { refreshWorkflowSourceFileBindingFromSave } from './workflow-file-bindings'; import type { InstanceAiContext } from '../../types'; // ── Credential cache ──────────────────────────────────────────────────────── @@ -765,7 +766,11 @@ export async function applyNodeCredentials( } try { - await context.workflowService.updateFromWorkflowJSON(workflowId, workflowJson); + const saved = await context.workflowService.updateFromWorkflowJSON(workflowId, workflowJson); + await refreshWorkflowSourceFileBindingFromSave(context, workflowId, { + versionId: saved.versionId, + checksum: saved.checksum, + }); } catch (error) { // If the final save fails, mark all previously-applied nodes as failed const saveError = `Failed to save workflow after credential apply: ${error instanceof Error ? error.message : 'Unknown error'}`; @@ -808,7 +813,11 @@ export async function applyNodeParameters( } try { - await context.workflowService.updateFromWorkflowJSON(workflowId, workflowJson); + const saved = await context.workflowService.updateFromWorkflowJSON(workflowId, workflowJson); + await refreshWorkflowSourceFileBindingFromSave(context, workflowId, { + versionId: saved.versionId, + checksum: saved.checksum, + }); } catch (error) { const saveError = `Failed to save workflow after parameter apply: ${error instanceof Error ? error.message : 'Unknown error'}`; for (const nodeName of result.applied) { @@ -909,7 +918,11 @@ export async function applyNodeChanges( // Single save for all changes try { - await context.workflowService.updateFromWorkflowJSON(workflowId, workflowJson); + const saved = await context.workflowService.updateFromWorkflowJSON(workflowId, workflowJson); + await refreshWorkflowSourceFileBindingFromSave(context, workflowId, { + versionId: saved.versionId, + checksum: saved.checksum, + }); result.applied = [...appliedNodes]; } catch (error) { const saveError = `Failed to save workflow: ${error instanceof Error ? error.message : 'Unknown error'}`; diff --git a/packages/@n8n/instance-ai/src/tools/workflows/workflow-build-remediation.ts b/packages/@n8n/instance-ai/src/tools/workflows/workflow-build-remediation.ts index 9e09b5d1bb9..4c7a54177f8 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/workflow-build-remediation.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/workflow-build-remediation.ts @@ -1,4 +1,5 @@ import type { WorkflowSourceCompileFailureReason } from './workflow-source-compiler'; +import { WorkflowSaveConflictError } from '../../errors/workflow-save-conflict.error'; import { createRemediation } from '../../workflow-loop/remediation'; import type { RemediationMetadata } from '../../workflow-loop/workflow-loop-state'; @@ -48,10 +49,22 @@ export function createCodeFixableRemediation(input: { }); } +export function createWorkflowModifiedExternallyRemediation(): RemediationMetadata { + return createCodeFixableRemediation({ + reason: 'workflow_modified_externally', + guidance: + 'The workflow was modified outside this conversation since your last save (canvas edit, setup, credential change, or version revert). Call workflows(action="get-as-code", workflowId), re-apply your intended change to the returned code, write it to the same filePath, then call build-workflow again with the same filePath.', + }); +} + export function createSaveFailureRemediation( error: unknown, hasBoundWorkflowId: boolean, ): RemediationMetadata { + if (error instanceof WorkflowSaveConflictError) { + return createWorkflowModifiedExternallyRemediation(); + } + const text = getFailureText(error); if (isCredentialSaveFailure(text)) { diff --git a/packages/@n8n/instance-ai/src/tools/workflows/workflow-build-telemetry.ts b/packages/@n8n/instance-ai/src/tools/workflows/workflow-build-telemetry.ts index 67e134b255a..1bc7ef237ea 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/workflow-build-telemetry.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/workflow-build-telemetry.ts @@ -10,7 +10,8 @@ export type BuildTelemetryStage = | 'parse' | 'validation' | 'name' - | 'save'; + | 'save' + | 'conflict'; export function trackWorkflowSourceBuild( context: InstanceAiContext, diff --git a/packages/@n8n/instance-ai/src/tools/workflows/workflow-file-bindings.ts b/packages/@n8n/instance-ai/src/tools/workflows/workflow-file-bindings.ts index 2a12e1792ab..4215089caaf 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/workflow-file-bindings.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/workflow-file-bindings.ts @@ -12,6 +12,7 @@ const workflowSourceFileBindingSchema = z.object({ filePath: z.string(), workflowId: z.string().optional(), workflowVersionId: z.string().optional(), + workflowChecksum: z.string().optional(), sourceHash: z.string().optional(), }); @@ -107,6 +108,71 @@ export async function saveWorkflowSourceFileBinding( return normalizedBinding; } +/** Bind a source file to an existing workflow, seeding version/checksum for stale-save detection. */ +export async function bindSourceFileToExistingWorkflow( + context: InstanceAiContext, + binding: WorkflowSourceFileBinding, + workflowId: string, +): Promise { + const workflow = await context.workflowService.get(workflowId); + return await saveWorkflowSourceFileBinding(context, { + ...binding, + workflowId, + workflowVersionId: workflow.versionId, + ...(workflow.checksum ? { workflowChecksum: workflow.checksum } : {}), + }); +} + +/** Refresh binding checksum/version from the workflow's current DB state. */ +export async function refreshWorkflowSourceFileBindingFromWorkflow( + context: InstanceAiContext, + workflowId: string, +): Promise { + const workflow = await context.workflowService.get(workflowId); + await refreshWorkflowSourceFileBindingFromSave(context, workflowId, { + versionId: workflow.versionId, + checksum: workflow.checksum, + }); +} + +/** Refresh the binding checksum/version after an agent-side DB patch outside build-workflow. */ +export async function refreshWorkflowSourceFileBindingFromSave( + context: InstanceAiContext, + workflowId: string, + saved: { versionId: string; checksum?: string }, +): Promise { + const threadBindings = await readThreadBindings(context); + const fallback = getFallbackBindings(context); + const entries: WorkflowSourceFileBinding[] = []; + + if (threadBindings) { + for (const binding of Object.values(threadBindings)) { + if (binding.workflowId === workflowId) entries.push(binding); + } + } + for (const binding of fallback.values()) { + if ( + binding.workflowId === workflowId && + !entries.some((e) => e.filePath === binding.filePath) + ) { + entries.push(binding); + } + } + + for (const binding of entries) { + const nextBinding: WorkflowSourceFileBinding = { + ...binding, + workflowVersionId: saved.versionId, + }; + if (saved.checksum !== undefined) { + nextBinding.workflowChecksum = saved.checksum; + } else { + delete nextBinding.workflowChecksum; + } + await saveWorkflowSourceFileBinding(context, nextBinding); + } +} + export async function readWorkflowSourceFile( context: InstanceAiContext, filePath: string, diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index 65e8e5a6489..19218996c2a 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -71,6 +71,8 @@ export interface WorkflowDetail extends WorkflowSummary { nodes: WorkflowNode[]; connections: Record; settings?: Record; + /** SHA-256 checksum of workflow content fields — used for optimistic-concurrency saves. */ + checksum?: string; } export interface WorkflowNode { @@ -297,7 +299,7 @@ export interface InstanceAiWorkflowService { updateFromWorkflowJSON( workflowId: string, json: WorkflowJSON, - options?: { projectId?: string }, + options?: { projectId?: string; expectedChecksum?: string }, ): Promise; archive(workflowId: string): Promise; unarchive(workflowId: string): Promise; diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index 9dc1bb09375..42fa9935718 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -1,22 +1,33 @@ // Mock the barrel import so these adapter tests only exercise local formatting helpers. -vi.mock('@n8n/instance-ai', () => ({ - wrapUntrustedData(content: string, source: string, label?: string): string { - const esc = (s: string) => - s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); - const safeLabel = label ? ` label="${esc(label)}"` : ''; - const safeContent = content.replace(/<\/untrusted_data/gi, '</untrusted_data'); - return `\n${safeContent}\n`; - }, - builderTemplatesOptionsFromEnv: () => ({}), - BuilderTemplatesService: class { - async getBundle() { - return { files: [], indexTxt: '', version: null }; - } - getVersion() { - return null; - } - }, -})); +vi.mock('@n8n/instance-ai', async () => { + const { WorkflowSaveConflictError } = await import( + '../../../../../@n8n/instance-ai/src/errors/workflow-save-conflict.error' + ); + return { + WorkflowSaveConflictError, + wrapUntrustedData(content: string, source: string, label?: string): string { + const esc = (s: string) => + s + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + const safeLabel = label ? ` label="${esc(label)}"` : ''; + const safeContent = content.replace(/<\/untrusted_data/gi, '</untrusted_data'); + return `\n${safeContent}\n`; + }, + builderTemplatesOptionsFromEnv: () => ({}), + deriveCredentialHosts: vi.fn().mockReturnValue([]), + BuilderTemplatesService: class { + async getBundle() { + return { files: [], indexTxt: '', version: null }; + } + getVersion() { + return null; + } + }, + }; +}); import type { Mock, Mocked, MockInstance } from 'vitest'; @@ -1207,7 +1218,9 @@ import type { DataTableRepository } from '@/modules/data-table/data-table.reposi import type { DataTableService } from '@/modules/data-table/data-table.service'; import type { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee'; import type { WorkflowJSON } from '@n8n/workflow-sdk'; +import { WorkflowSaveConflictError } from '../../../../../@n8n/instance-ai/src/errors/workflow-save-conflict.error'; import type { WorkflowService } from '@/workflows/workflow.service'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; import type { License } from '@/license'; import type { RoleService } from '@/services/role.service'; @@ -2345,6 +2358,42 @@ describe('createWorkflowAdapter', () => { expect(updateData.nodes[0].credentials).toBeUndefined(); }); + it('forwards expectedChecksum to workflowService.update', async () => { + const { adapter, mockWorkflowService } = createWorkflowAdapterForTests(); + + await adapter.updateFromWorkflowJSON('wf-new', minimalWorkflowJSON, { + expectedChecksum: 'expected-checksum', + }); + + expect(mockWorkflowService.update).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'wf-new', + expect.objectContaining({ expectedChecksum: 'expected-checksum', source: 'n8n-ai' }), + ); + }); + + it('throws WorkflowSaveConflictError when expectedChecksum mismatches', async () => { + const { adapter, mockWorkflowService } = createWorkflowAdapterForTests(); + mockWorkflowService.update.mockRejectedValueOnce(new ConflictError('conflict')); + + await expect( + adapter.updateFromWorkflowJSON('wf-new', minimalWorkflowJSON, { + expectedChecksum: 'stale-checksum', + }), + ).rejects.toBeInstanceOf(WorkflowSaveConflictError); + }); + + it('returns a checksum on create and update saves', async () => { + const { adapter } = createWorkflowAdapterForTests(); + + const created = await adapter.createFromWorkflowJSON(minimalWorkflowJSON); + const updated = await adapter.updateFromWorkflowJSON('wf-new', minimalWorkflowJSON); + + expect(created.checksum).toEqual(expect.any(String)); + expect(updated.checksum).toEqual(expect.any(String)); + }); + it('clears the AI-builder temporary marker when promoting the main workflow', async () => { const { adapter, mockAiBuilderTemporaryWorkflowRepository, mockWorkflowRepository } = createWorkflowAdapterForTests(); diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index 996a6d046ad..b2065ba5cf9 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -45,6 +45,7 @@ import { builderTemplatesOptionsFromEnv, wrapUntrustedData, deriveCredentialHosts, + WorkflowSaveConflictError, } from '@n8n/instance-ai'; import type { WorkflowJSON } from '@n8n/workflow-sdk'; import { upsertEvaluationConfigSchema } from '@n8n/api-types'; @@ -105,9 +106,11 @@ import { UnexpectedError, jsonParse, createRunExecutionData, + calculateWorkflowChecksum, } from 'n8n-workflow'; import { ActiveExecutions } from '@/active-executions'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsService } from '@/credentials/credentials.service'; import { EvaluationConfigService } from '@/evaluation.ee/evaluation-config.service'; @@ -458,7 +461,7 @@ export class InstanceAiAdapterService { throw new Error(`Workflow ${workflowId} not found or not accessible`); } - return toWorkflowDetail(workflow, { redactParameters }); + return await toWorkflowDetailWithChecksum(workflow, { redactParameters }); }, async archive(workflowId: string) { @@ -701,13 +704,13 @@ export class InstanceAiAdapterService { }); } - return toWorkflowDetail(updated, { redactParameters }); + return await toWorkflowDetailWithChecksum(updated, { redactParameters }); }, async updateFromWorkflowJSON( workflowId: string, json: WorkflowJSON, - _options?: { projectId?: string }, + options?: { projectId?: string; expectedChecksum?: string }, ) { assertNotReadOnly(); // Strip redactionPolicy if the user lacks the required directional scope — @@ -761,8 +764,12 @@ export class InstanceAiAdapterService { updated = await workflowService.update(user, updateData, workflowId, { source: 'n8n-ai', + ...(options?.expectedChecksum ? { expectedChecksum: options.expectedChecksum } : {}), }); } catch (error) { + if (error instanceof ConflictError) { + throw new WorkflowSaveConflictError(workflowId); + } logger.warn('AI-builder workflow save failed', { threadId, workflowId, @@ -778,7 +785,7 @@ export class InstanceAiAdapterService { }); } - return toWorkflowDetail(updated, { redactParameters }); + return await toWorkflowDetailWithChecksum(updated, { redactParameters }); }, async listVersions(workflowId, options) { @@ -3388,3 +3395,12 @@ function toWorkflowDetail( settings: workflow.settings as Record | undefined, }; } + +async function toWorkflowDetailWithChecksum( + workflow: WorkflowEntity, + options?: { redactParameters?: boolean }, +): Promise { + const detail = toWorkflowDetail(workflow, options); + detail.checksum = await calculateWorkflowChecksum(workflow); + return detail; +}