feat(core): Invalidate instance-ai build-workflow cache on canvas edits (#31274)

This commit is contained in:
Raúl Gómez Morales 2026-06-03 13:09:23 +02:00 committed by GitHub
parent 7bd7b9943b
commit e27c4feaca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 128 additions and 23 deletions

View File

@ -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,

View File

@ -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(),

View File

@ -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(),

View File

@ -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) {

View File

@ -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,

View File

@ -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

View File

@ -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> = {};