fix(core): Detect stale Instance AI workflow saves (no-changelog) (#33711)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Riqwan Thamir
2026-07-10 09:12:33 +00:00
committed by GitHub
co-authored by Cursor
parent b6bf7b0429
commit fccaace7f7
20 changed files with 692 additions and 55 deletions
@@ -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',
});
}
}
+1
View File
@@ -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,
@@ -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 () => {
@@ -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({
@@ -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<typeof
});
const patched = applyPinData(wf, generated);
if (patched !== wf) {
await context.workflowService.updateFromWorkflowJSON(input.workflowId, patched, {
...(input.projectId ? { projectId: input.projectId } : {}),
const saved = await context.workflowService.updateFromWorkflowJSON(
input.workflowId,
patched,
{
...(input.projectId ? { projectId: input.projectId } : {}),
},
);
await refreshWorkflowSourceFileBindingFromSave(context, input.workflowId, {
versionId: saved.versionId,
checksum: saved.checksum,
});
workflowWithPinData = patched;
}
@@ -24,6 +24,7 @@ import {
summarizeWorkflowStructure,
} from './workflows/summarize-workflow';
import { validateWorkflowConfig } from './workflows/validate-workflow.service';
import { refreshWorkflowSourceFileBindingFromWorkflow } from './workflows/workflow-file-bindings';
import { getReferencedWorkflowIds } from './workflows/workflow-json-utils';
// ── Action schemas ──────────────────────────────────────────────────────────
@@ -445,6 +446,10 @@ async function handleGetAsCode(
try {
const json = await context.workflowService.getAsWorkflowJSON(input.workflowId, input.versionId);
const code = generateWorkflowCode(json);
// Historical reads must not advance the optimistic-concurrency lock.
if (!input.versionId) {
await refreshWorkflowSourceFileBindingFromWorkflow(context, input.workflowId);
}
return { workflowId: input.workflowId, name: json.name, code };
} catch (error) {
return {
@@ -485,6 +490,7 @@ async function handleDelete(
}
await context.workflowService.archive(input.workflowId);
await refreshWorkflowSourceFileBindingFromWorkflow(context, input.workflowId);
return { success: true };
}
@@ -515,6 +521,7 @@ async function handleUnarchive(
}
await context.workflowService.unarchive(input.workflowId);
await refreshWorkflowSourceFileBindingFromWorkflow(context, input.workflowId);
return { success: true };
}
@@ -738,6 +745,7 @@ async function handleSetup(
if (!resumeData.approved) {
if (state.preTestSnapshot) {
await context.workflowService.updateFromWorkflowJSON(input.workflowId, state.preTestSnapshot);
await refreshWorkflowSourceFileBindingFromWorkflow(context, input.workflowId);
state.preTestSnapshot = null;
}
return {
@@ -827,6 +835,7 @@ async function handleUpdate(
try {
await context.workflowService.updateFromWorkflowJSON(input.workflowId, input.workflow);
await refreshWorkflowSourceFileBindingFromWorkflow(context, input.workflowId);
return { success: true, workflowId: input.workflowId };
} catch (error) {
return {
@@ -882,6 +891,7 @@ async function handlePublish(
try {
for (const supportingWorkflowId of supportingWorkflowIds) {
await context.workflowService.publish(supportingWorkflowId);
await refreshWorkflowSourceFileBindingFromWorkflow(context, supportingWorkflowId);
publishedSupportingWorkflowIds.push(supportingWorkflowId);
publishedWorkflowIds.push(supportingWorkflowId);
}
@@ -896,6 +906,7 @@ async function handlePublish(
: {}),
});
publishedWorkflowIds.push(input.workflowId);
await refreshWorkflowSourceFileBindingFromWorkflow(context, input.workflowId);
return {
success: true,
@@ -953,6 +964,7 @@ async function rollbackPublishedWorkflows(
} else {
await context.workflowService.unpublish(workflowId);
}
await refreshWorkflowSourceFileBindingFromWorkflow(context, workflowId);
result.rolledBackWorkflowIds.push(workflowId);
} catch (error) {
result.rollbackErrors.push({
@@ -1018,6 +1030,7 @@ async function handleUnpublish(
try {
await context.workflowService.unpublish(input.workflowId);
await refreshWorkflowSourceFileBindingFromWorkflow(context, input.workflowId);
return { success: true };
} catch (error) {
return {
@@ -1074,6 +1087,7 @@ async function handleRestoreVersion(
try {
await context.workflowService.restoreVersion!(input.workflowId, input.versionId);
await refreshWorkflowSourceFileBindingFromWorkflow(context, input.workflowId);
return { success: true };
} catch (error) {
return {
@@ -29,7 +29,11 @@ function makeContext(options: MakeContextOptions = {}): OrchestrationContext {
userId: 'user-1',
workflowService: {
getAsWorkflowJSON: vi.fn().mockResolvedValue(workflowJson),
updateFromWorkflowJSON: vi.fn().mockResolvedValue(undefined),
updateFromWorkflowJSON: vi.fn().mockResolvedValue({
id: 'wf-1',
versionId: 'v-1',
checksum: 'checksum-1',
}),
} as never,
credentialService: {
get: vi.fn().mockResolvedValue({ id: 'cred-1', name: 'My Key' }),
@@ -1,4 +1,5 @@
import { executeTool } from '../../../__tests__/tool-test-utils';
import { WorkflowSaveConflictError } from '../../../errors/workflow-save-conflict.error';
import { emitTraceOnlyChildRun } from '../../../tracing/langsmith-tracing';
import type { InstanceAiContext } from '../../../types';
import type { WorkflowBuildOutcome } from '../../../workflow-loop/workflow-loop-state';
@@ -131,11 +132,24 @@ function makeContext(input: {
runId: 'run-1',
workflowService: {
createFromWorkflowJSON: vi.fn(
async () => 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<BuildToolOutput>(tool, {
filePath,
workflowId: 'wf-bound',
});
vi.mocked(context.workflowService.updateFromWorkflowJSON).mockRejectedValueOnce(
new WorkflowSaveConflictError('wf-bound'),
);
const result = await executeTool<BuildToolOutput>(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();
});
@@ -27,7 +27,11 @@ function createMockContext(overrides?: Partial<InstanceAiContext>): 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');
@@ -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');
});
});
@@ -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',
});
});
});
@@ -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,
@@ -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, {
@@ -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'}`;
@@ -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)) {
@@ -10,7 +10,8 @@ export type BuildTelemetryStage =
| 'parse'
| 'validation'
| 'name'
| 'save';
| 'save'
| 'conflict';
export function trackWorkflowSourceBuild(
context: InstanceAiContext,
@@ -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<WorkflowSourceFileBinding> {
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<void> {
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<void> {
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,
+3 -1
View File
@@ -71,6 +71,8 @@ export interface WorkflowDetail extends WorkflowSummary {
nodes: WorkflowNode[];
connections: Record<string, unknown>;
settings?: Record<string, unknown>;
/** 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<WorkflowDetail>;
archive(workflowId: string): Promise<void>;
unarchive(workflowId: string): Promise<void>;
@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const safeLabel = label ? ` label="${esc(label)}"` : '';
const safeContent = content.replace(/<\/untrusted_data/gi, '&lt;/untrusted_data');
return `<untrusted_data source="${esc(source)}"${safeLabel}>\n${safeContent}\n</untrusted_data>`;
},
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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const safeLabel = label ? ` label="${esc(label)}"` : '';
const safeContent = content.replace(/<\/untrusted_data/gi, '&lt;/untrusted_data');
return `<untrusted_data source="${esc(source)}"${safeLabel}>\n${safeContent}\n</untrusted_data>`;
},
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();
@@ -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<string, unknown> | undefined,
};
}
async function toWorkflowDetailWithChecksum(
workflow: WorkflowEntity,
options?: { redactParameters?: boolean },
): Promise<WorkflowDetail> {
const detail = toWorkflowDetail(workflow, options);
detail.checksum = await calculateWorkflowChecksum(workflow);
return detail;
}