mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-19 01:45:48 +08:00
fix(core): Detect stale Instance AI workflow saves (no-changelog) (#33711)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+5
-1
@@ -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' }),
|
||||
|
||||
+78
-6
@@ -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();
|
||||
});
|
||||
|
||||
+45
-9
@@ -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');
|
||||
|
||||
|
||||
+15
@@ -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');
|
||||
});
|
||||
});
|
||||
+113
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
+67
-18
@@ -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, '<').replace(/>/g, '>');
|
||||
const safeLabel = label ? ` label="${esc(label)}"` : '';
|
||||
const safeContent = content.replace(/<\/untrusted_data/gi, '</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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
const safeLabel = label ? ` label="${esc(label)}"` : '';
|
||||
const safeContent = content.replace(/<\/untrusted_data/gi, '</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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user