From 3d65582265f63b3f57b0216362e3b714f9ac0137 Mon Sep 17 00:00:00 2001 From: Kristofer Jussmann Date: Mon, 18 May 2026 13:07:26 +0300 Subject: [PATCH] Validate workflow Blender actions server-side --- app/api/ai/workflow-step/route.ts | 40 ++++++++++++++++--- lib/orchestration/workflow-advisor.ts | 12 ++++-- ...st-workflow-direct-tool-recommendations.ts | 4 +- .../test-workflow-step-action-validation.ts | 18 +++++++++ 4 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 scripts/test/test-workflow-step-action-validation.ts diff --git a/app/api/ai/workflow-step/route.ts b/app/api/ai/workflow-step/route.ts index 9e5b3c1..83c11a0 100644 --- a/app/api/ai/workflow-step/route.ts +++ b/app/api/ai/workflow-step/route.ts @@ -16,6 +16,7 @@ import { createBlenderAgentV2 } from "@/lib/ai/agents" import { createMcpClient } from "@/lib/mcp" import { getLocalAssetLibraryStatus } from "@/lib/mcp/client" import type { WorkflowStepResult } from "@/lib/orchestration/workflow-types" +import { normalizeWorkflowBlenderAction } from "@/lib/orchestration/workflow-advisor" import { z } from "zod" // --------------------------------------------------------------------------- @@ -34,12 +35,39 @@ const RequestSchema = z.object({ title: z.string(), description: z.string(), recommendedTool: z.enum(["blender_agent", "neural", "manual"]), - category: z.string(), + category: z.enum([ + "geometry", + "topology", + "uv", + "texturing", + "rigging", + "animation", + "lighting", + "export", + "composition", + "effects", + "rendering", + "other", + ]), neuralProvider: z.string().optional(), blenderAction: z.string().optional(), }).optional(), }) +function sanitizeWorkflowStep(step: NonNullable["step"]>) { + if (step.recommendedTool !== "blender_agent") { + return { + ...step, + blenderAction: undefined, + } + } + + return { + ...step, + blenderAction: normalizeWorkflowBlenderAction(step.category, step.recommendedTool, step.blenderAction), + } +} + // --------------------------------------------------------------------------- // Step executors // --------------------------------------------------------------------------- @@ -252,10 +280,12 @@ export async function POST(request: Request) { let result: WorkflowStepResult - if (step.recommendedTool === "neural") { - result = await executeNeuralStep(step, userRequest) - } else if (step.recommendedTool === "blender_agent") { - result = await executeBlenderAgentStep(step, userRequest) + const sanitizedStep = sanitizeWorkflowStep(step) + + if (sanitizedStep.recommendedTool === "neural") { + result = await executeNeuralStep(sanitizedStep, userRequest) + } else if (sanitizedStep.recommendedTool === "blender_agent") { + result = await executeBlenderAgentStep(sanitizedStep, userRequest) } else { result = { stepId, diff --git a/lib/orchestration/workflow-advisor.ts b/lib/orchestration/workflow-advisor.ts index f28d860..2c1c31f 100644 --- a/lib/orchestration/workflow-advisor.ts +++ b/lib/orchestration/workflow-advisor.ts @@ -160,14 +160,18 @@ interface LLMWorkflowResponse { overallTips: string[] } -const VALID_BLENDER_ACTIONS = new Set(TOOL_REGISTRY.map((tool) => tool.name)) +export const VALID_WORKFLOW_BLENDER_ACTIONS = new Set(TOOL_REGISTRY.map((tool) => tool.name)) -function normalizeBlenderAction(category: WorkflowCategory, recommendedTool: WorkflowTool, action?: string): string | undefined { +export function normalizeWorkflowBlenderAction( + category: WorkflowCategory, + recommendedTool: WorkflowTool, + action?: string +): string | undefined { if (recommendedTool !== "blender_agent") return undefined const categoryDefault = CATEGORY_DEFAULTS[category] ?? CATEGORY_DEFAULTS.other const candidate = action?.trim() - if (candidate && VALID_BLENDER_ACTIONS.has(candidate)) { + if (candidate && VALID_WORKFLOW_BLENDER_ACTIONS.has(candidate)) { if (candidate === "execute_code" && categoryDefault.blenderAction && categoryDefault.blenderAction !== "execute_code") { return categoryDefault.blenderAction } @@ -430,7 +434,7 @@ export async function generateWorkflowProposal( tips: step.tips, status: "pending", neuralProvider: step.neuralProvider ?? categoryDefault.neuralProvider, - blenderAction: normalizeBlenderAction(step.category, step.recommendedTool, step.blenderAction), + blenderAction: normalizeWorkflowBlenderAction(step.category, step.recommendedTool, step.blenderAction), } }) diff --git a/scripts/test/test-workflow-direct-tool-recommendations.ts b/scripts/test/test-workflow-direct-tool-recommendations.ts index 79b5eda..3e218f6 100644 --- a/scripts/test/test-workflow-direct-tool-recommendations.ts +++ b/scripts/test/test-workflow-direct-tool-recommendations.ts @@ -30,8 +30,8 @@ async function main() { const source = readFileSync("lib/orchestration/workflow-advisor.ts", "utf8") assert.match(source, /blenderAction is required when recommendedTool is "blender_agent"/) - assert.match(source, /VALID_BLENDER_ACTIONS/) - assert.match(source, /normalizeBlenderAction/) + assert.match(source, /VALID_WORKFLOW_BLENDER_ACTIONS/) + assert.match(source, /normalizeWorkflowBlenderAction/) assert.match(source, /animation:[\s\S]*blenderAction: "inspect_animation_data"/) assert.match(source, /setup_studio_scene/) assert.match(source, /render_thumbnail_to_path/) diff --git a/scripts/test/test-workflow-step-action-validation.ts b/scripts/test/test-workflow-step-action-validation.ts new file mode 100644 index 0000000..b78b3d7 --- /dev/null +++ b/scripts/test/test-workflow-step-action-validation.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" + +const routeSource = readFileSync("app/api/ai/workflow-step/route.ts", "utf8") +const advisorSource = readFileSync("lib/orchestration/workflow-advisor.ts", "utf8") + +assert.match(advisorSource, /export function normalizeWorkflowBlenderAction/) +assert.match(advisorSource, /VALID_WORKFLOW_BLENDER_ACTIONS/) +assert.match(advisorSource, /candidate === "execute_code"/) + +assert.match(routeSource, /normalizeWorkflowBlenderAction/) +assert.match(routeSource, /function sanitizeWorkflowStep/) +assert.match(routeSource, /category: z\.enum\(\[/) +assert.match(routeSource, /const sanitizedStep = sanitizeWorkflowStep\(step\)/) +assert.match(routeSource, /executeBlenderAgentStep\(sanitizedStep, userRequest\)/) +assert.doesNotMatch(routeSource, /executeBlenderAgentStep\(step, userRequest\)/) + +console.log("Workflow step action validation tests passed")