mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 10:39:23 +02:00
feat(core): Invalidate instance-ai build-workflow cache on canvas edits (#31274)
This commit is contained in:
parent
7bd7b9943b
commit
e27c4feaca
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ function createMockContext(overrides?: Partial<InstanceAiContext>): 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(),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ function createMockContext(overrides?: Partial<InstanceAiContext>): 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(),
|
||||
|
|
|
|||
|
|
@ -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<string>()).add(created.id);
|
||||
lastCodeVersionId = created.versionId;
|
||||
return await createSuccessResponse(created.id);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -251,6 +251,16 @@ export interface InstanceAiWorkflowService {
|
|||
get(workflowId: string): Promise<WorkflowDetail>;
|
||||
/** Get the workflow as the SDK's WorkflowJSON (full node data for generateWorkflowCode). */
|
||||
getAsWorkflowJSON(workflowId: string): Promise<WorkflowJSON>;
|
||||
/** 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -29,24 +29,7 @@ export class WorkflowFinderService {
|
|||
em?: EntityManager;
|
||||
} = {},
|
||||
) {
|
||||
let where: FindOptionsWhere<SharedWorkflow> = {};
|
||||
|
||||
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<FindOptionsWhere<SharedWorkflow>> {
|
||||
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<SharedWorkflow> = {};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user