From e27c4feaca1c8fda39040b9b717448bfdeb9e3ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Wed, 3 Jun 2026 13:09:23 +0200 Subject: [PATCH] feat(core): Invalidate instance-ai build-workflow cache on canvas edits (#31274) --- .../evaluations/harness/stub-services.ts | 19 +++++- .../__tests__/parse-file.tool.test.ts | 2 + .../__tests__/setup-workflow.service.test.ts | 2 + .../tools/workflows/build-workflow.tool.ts | 33 ++++++++-- packages/@n8n/instance-ai/src/types.ts | 10 +++ .../instance-ai.adapter.service.ts | 20 ++++++ .../src/workflows/workflow-finder.service.ts | 65 ++++++++++++++----- 7 files changed, 128 insertions(+), 23 deletions(-) diff --git a/packages/@n8n/instance-ai/evaluations/harness/stub-services.ts b/packages/@n8n/instance-ai/evaluations/harness/stub-services.ts index 2345d60e442..b63c3d7bb49 100644 --- a/packages/@n8n/instance-ai/evaluations/harness/stub-services.ts +++ b/packages/@n8n/instance-ai/evaluations/harness/stub-services.ts @@ -48,6 +48,12 @@ import type { // Public API // --------------------------------------------------------------------------- +// Single version id reported for every stubbed workflow. The stub doesn't model +// version increments, so create/update, getWorkflowHead, and getWorkflowSnapshot +// must all report the same value — otherwise the build-workflow patch cache +// always sees a version mismatch and the cache-hit path is never exercised. +const EVAL_WORKFLOW_VERSION_ID = 'eval-version'; + export interface StubServiceHandle { context: InstanceAiContext; /** Every WorkflowJSON passed to `workflowService.createFromWorkflowJSON`. */ @@ -83,6 +89,17 @@ export async function createStubServices( const latest = capturedWorkflows[capturedWorkflows.length - 1]; return latest ?? { id: workflowId, name: 'empty', nodes: [], connections: {} }; }, + async getWorkflowHead() { + return { versionId: EVAL_WORKFLOW_VERSION_ID, updatedAt: 0 }; + }, + async getWorkflowSnapshot(workflowId: string) { + const latest = capturedWorkflows[capturedWorkflows.length - 1]; + return { + json: latest ?? { id: workflowId, name: 'empty', nodes: [], connections: {} }, + versionId: EVAL_WORKFLOW_VERSION_ID, + updatedAt: 0, + }; + }, async createFromWorkflowJSON(json: WorkflowJSON) { capturedWorkflows.push(json); return { @@ -514,7 +531,7 @@ function emptyWorkflowDetail(id: string): WorkflowDetail { return { id, name: 'eval-workflow', - versionId: 'v1', + versionId: EVAL_WORKFLOW_VERSION_ID, activeVersionId: null, isArchived: false, createdAt: now, diff --git a/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts b/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts index 0b425a83de2..48ccf9c05b2 100644 --- a/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/attachments/__tests__/parse-file.tool.test.ts @@ -17,6 +17,8 @@ function createMockContext(overrides?: Partial): InstanceAiCo list: vi.fn(), get: vi.fn(), getAsWorkflowJSON: vi.fn(), + getWorkflowHead: vi.fn(), + getWorkflowSnapshot: vi.fn(), createFromWorkflowJSON: vi.fn(), updateFromWorkflowJSON: vi.fn(), archive: vi.fn(), diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts index 62a366df557..4a596ca4135 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts @@ -23,6 +23,8 @@ function createMockContext(overrides?: Partial): InstanceAiCo list: vi.fn(), get: vi.fn(), getAsWorkflowJSON: vi.fn(), + getWorkflowHead: vi.fn(), + getWorkflowSnapshot: vi.fn(), createFromWorkflowJSON: vi.fn(), updateFromWorkflowJSON: vi.fn(), archive: vi.fn(), diff --git a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts index 8b383f90895..1c3e2ffa3a6 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts @@ -327,7 +327,11 @@ async function promoteMainWorkflow(context: InstanceAiContext, workflowId: strin export function createBuildWorkflowTool(context: InstanceAiContext) { // Keeps the last code submitted (or patched) so patches work even before save, // and always match the LLM's own code — not a roundtripped version. + // lastCodeVersionId pins the cache to the workflow version it was derived + // from; a mismatch on the next turn (user edited the workflow in the canvas) + // invalidates the cache so patches don't silently overwrite the user's work. let lastCode: string | null = null; + let lastCodeVersionId: string | null = null; let planGuardRejectionCount = 0; const rejectPlanGuardCall = () => { @@ -432,13 +436,32 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { if (patches) { // Patch mode: apply str_replace to existing code. - // Source priority: lastCode (same session) → fetch from backend (cross-session) + // Cache-hit fast path uses a cheap head check (versionId only, no + // nodes/connections payload) to confirm `lastCode` still matches the + // server. On match we reuse the cached code; on drift we invalidate + // and fall through to the snapshot fetch below, which returns body + // + versionId in one round-trip. + if (lastCode && lastCodeVersionId && workflowId) { + try { + const head = await context.workflowService.getWorkflowHead(workflowId); + if (head.versionId !== lastCodeVersionId) { + lastCode = null; + lastCodeVersionId = null; + } + } catch { + // Best-effort: a transient head-lookup failure shouldn't break + // patch mode. If the cache is stale, patches will either fail to + // apply cleanly or the next save will surface the conflict. + } + } + let baseCode = lastCode; if (!baseCode && workflowId) { try { - const json = await context.workflowService.getAsWorkflowJSON(workflowId); - baseCode = generateWorkflowCode(json); - lastCode = baseCode; // Sync so future patches match this code + const snapshot = await context.workflowService.getWorkflowSnapshot(workflowId); + baseCode = generateWorkflowCode(snapshot.json); + lastCode = baseCode; + lastCodeVersionId = snapshot.versionId; } catch { return { success: false, @@ -638,6 +661,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { json, projectId ? { projectId } : undefined, ); + lastCodeVersionId = updated.versionId; return await createSuccessResponse(updated.id); } else { const created = await context.workflowService.createFromWorkflowJSON(json, { @@ -645,6 +669,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { markAsAiTemporary: true, }); (context.aiCreatedWorkflowIds ??= new Set()).add(created.id); + lastCodeVersionId = created.versionId; return await createSuccessResponse(created.id); } } catch (error) { diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index 2b0df228de6..572718dff6f 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -251,6 +251,16 @@ export interface InstanceAiWorkflowService { get(workflowId: string): Promise; /** Get the workflow as the SDK's WorkflowJSON (full node data for generateWorkflowCode). */ getAsWorkflowJSON(workflowId: string): Promise; + /** Cheap version-only lookup. The adapter projects just `versionId` and + * `updatedAt` from the workflow row, skipping `nodes`/`connections`/etc. + * Use to validate per-session caches when the body isn't needed. */ + getWorkflowHead(workflowId: string): Promise<{ versionId: string; updatedAt: number }>; + /** Single fetch returning the SDK WorkflowJSON together with the version it + * was derived from. Use on cache miss (or drift) so the fresh body and the + * versionId you'll pin to it land in one round-trip. */ + getWorkflowSnapshot( + workflowId: string, + ): Promise<{ json: WorkflowJSON; versionId: string; updatedAt: number }>; /** Create a workflow from SDK-produced WorkflowJSON (full NodeJSON with typeVersion, credentials, etc.). */ createFromWorkflowJSON( json: WorkflowJSON, diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index 106dced9a70..761024dd2c3 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -470,6 +470,26 @@ export class InstanceAiAdapterService { return toWorkflowJSON(wf, { redactParameters }); }, + async getWorkflowHead(workflowId: string) { + const head = await workflowFinderService.findWorkflowHeadForUser(workflowId, user, [ + 'workflow:read', + ]); + if (!head) throw new Error(`Workflow ${workflowId} not found or not accessible`); + return { versionId: head.versionId, updatedAt: head.updatedAt.getTime() }; + }, + + async getWorkflowSnapshot(workflowId: string) { + const wf = await workflowFinderService.findWorkflowForUser(workflowId, user, [ + 'workflow:read', + ]); + if (!wf) throw new Error(`Workflow ${workflowId} not found or not accessible`); + return { + json: toWorkflowJSON(wf, { redactParameters }), + versionId: wf.versionId, + updatedAt: wf.updatedAt.getTime(), + }; + }, + async getLatestRunData(workflowId: string) { // Caller must be able to read the workflow to see its execution history. // Silent null on no-access keeps validation usable even when access was diff --git a/packages/cli/src/workflows/workflow-finder.service.ts b/packages/cli/src/workflows/workflow-finder.service.ts index 20459737f67..784890dcc89 100644 --- a/packages/cli/src/workflows/workflow-finder.service.ts +++ b/packages/cli/src/workflows/workflow-finder.service.ts @@ -29,24 +29,7 @@ export class WorkflowFinderService { em?: EntityManager; } = {}, ) { - let where: FindOptionsWhere = {}; - - if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { - const [projectRoles, workflowRoles] = await Promise.all([ - this.roleService.rolesWithScope('project', scopes, options.em), - this.roleService.rolesWithScope('workflow', scopes, options.em), - ]); - - where = { - role: In(workflowRoles), - project: { - projectRelations: { - role: In(projectRoles), - userId: user.id, - }, - }, - }; - } + const where = await this.buildSingleWorkflowReadWhere(user, scopes, options.em); const sharedWorkflow = await this.sharedWorkflowRepository.findWorkflowWithOptions(workflowId, { where, @@ -63,6 +46,52 @@ export class WorkflowFinderService { return sharedWorkflow.workflow; } + /** + * Read-access check that projects only `versionId` and `updatedAt` from the + * workflow row — skips the heavyweight `nodes`/`connections`/`settings` JSON + * columns. Use for cache-validity checks where the body isn't needed. + */ + async findWorkflowHeadForUser( + workflowId: string, + user: User, + scopes: Scope[], + ): Promise<{ versionId: string; updatedAt: Date } | null> { + const where = await this.buildSingleWorkflowReadWhere(user, scopes); + const sw = await this.sharedWorkflowRepository.findOne({ + where: { workflowId, ...where }, + relations: { workflow: true }, + select: { + workflowId: true, + workflow: { id: true, versionId: true, updatedAt: true }, + }, + }); + if (!sw?.workflow) return null; + return { versionId: sw.workflow.versionId, updatedAt: sw.workflow.updatedAt }; + } + + private async buildSingleWorkflowReadWhere( + user: User, + scopes: Scope[], + em?: EntityManager, + ): Promise> { + if (hasGlobalScope(user, scopes, { mode: 'allOf' })) return {}; + + const [projectRoles, workflowRoles] = await Promise.all([ + this.roleService.rolesWithScope('project', scopes, em), + this.roleService.rolesWithScope('workflow', scopes, em), + ]); + + return { + role: In(workflowRoles), + project: { + projectRelations: { + role: In(projectRoles), + userId: user.id, + }, + }, + }; + } + private async findAllWhere(user: User, scopes: Scope[], folderId?: string, projectId?: string) { let where: FindOptionsWhere = {};