From 8e6b519a0dac4e33b9cb0057edcd889072f4420b Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:08:14 +0100 Subject: [PATCH] feat(aichat): simplify flow mode edits (#6981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * draft * Phase 1: Remove deprecated granular flow AI tools Simplify AI chat flow mode to use only YAML-based editing: - Remove all commented-out granular tools (add_step, remove_step, set_code, etc.) - Clean up FlowAIChatHelpers interface to only essential methods - Update system prompts to focus on YAML-only workflow - Remove unused imports and type definitions This is part of a larger refactoring to simplify the flow editing experience to a single YAML editing tool with automatic diff visualization. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * use minified json * use openflow in system prompt * handle inputs * cleaning * cleaning * diffmode in flowgraph * remove acceptrejectmodule * use new diff mode * cleaning * better props * better logic * cleaning * accept reject logic * use get set * draft manager * use diff manager * draft * Refactor flowDiffManager to be instance-based with auto-computation - Remove singleton export, making it instantiable per FlowGraphV2 - Add afterFlow state tracking for auto-diff computation - Add beforeInputSchema/afterInputSchema for schema change tracking - Add $effect for reactive auto-computation when beforeFlow/afterFlow changes - Add setAfterFlow() and setInputSchemas() methods - Simplify accept/reject methods to just mark pending=false - Add validation to throw error when accepting/rejecting without beforeFlow - Update setSnapshot to accept undefined for clearing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Refactor FlowGraphV2 to own diffManager instance - Import and create diffManager instance per FlowGraphV2 - Remove onAcceptModule and onRejectModule props - Add validation $effect to error if both diffBeforeFlow and moduleActions provided - Add $effect to sync props (diffBeforeFlow or moduleActions) to diffManager - Add $effect to watch current flow changes and update afterFlow - Replace computedDiff with diffManager.moduleActions - Use raw modules instead of merged flow (diffManager handles merging) - Expose getDiffManager() and setBeforeFlow() methods - Pass diffManager to graph context instead of callbacks - Remove $inspect for removed props 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Update FlowModuleSchemaMap to use FlowGraphV2's diffManager - Remove import of flowDiffManager singleton - Update setBeforeFlow to call graph.setBeforeFlow() - Update setModuleActions and getModuleActions to use graph.getDiffManager() - Add getDiffManager() proxy method - Simplify handleAcceptModule and handleRejectModule to use new API - Handle editor state separately from diff operations - Remove diffBeforeFlow, moduleActions, onAcceptModule, onRejectModule props passed to FlowGraphV2 - Remove onAcceptModule and onRejectModule from Props interface and destructured props 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Update FlowAIChat to use flowModuleSchemaMap's diffManager - Remove import of flowDiffManager singleton - Update revertToSnapshot to use flowModuleSchemaMap.getDiffManager() - Add null check for diffManager before using 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Verify FlowGraphDiffViewer compatibility with refactored architecture FlowGraphDiffViewer already uses the correct prop patterns: - Before graph: moduleActions prop (display-only mode) - After graph: diffBeforeFlow prop (full diff mode with auto-computation) Each FlowGraphV2 instance creates its own diffManager, making the side-by-side view work correctly with independent diff state per graph. No code changes required. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Update graph components to use diffManager instead of callbacks - Update graphBuilder.svelte.ts to pass diffManager instead of onAcceptModule/onRejectModule - Update InputNode and ModuleN type definitions with diffManager - Update ModuleNode.svelte to pass diffManager to MapItem - Update MapItem.svelte to pass diffManager to FlowModuleSchemaItem - Update FlowModuleSchemaItem.svelte to use diffManager directly for accept/reject - Replace callback-based accept/reject with direct diffManager calls - Only show accept/reject buttons when beforeFlow exists and action is pending 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix removed modules not showing in diff viewer Problem: After refactoring, removed modules were no longer appearing in the diff viewer because we changed effectiveModules from using the merged flow (which includes removed modules) to using raw modules. Solution: - Add mergedFlow state to flowDiffManager to store timeline's merged flow - Add markRemovedAsShadowed parameter support for side-by-side view - Store timeline.mergedFlow in auto-computation $effect - Add getter for mergedFlow and setMarkRemovedAsShadowed method - Clear mergedFlow in clearSnapshot() - Update FlowGraphV2 to set markRemovedAsShadowed in diffManager - Update effectiveModules/FailureModule/PreprocessorModule to use mergedFlow The merged flow contains all modules including removed ones, enabling: - Unified view: Removed modules appear in red with "removed" badge - Side-by-side view: Removed modules show as shadowed in After graph 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Simplify accept/reject logic by removing actions instead of toggling pending state Previously, accepting or rejecting a module action would set pending to false but keep the action in the moduleActions map. This caused a bug where the $effect would overwrite moduleActions with fresh actions having pending: true, making accept/reject buttons reappear on previously handled modules. Now, when a user accepts or rejects a module action, we remove it entirely from the moduleActions map. This is simpler and fixes the button reappearing issue. Changes: - acceptModule: Remove action from moduleActions instead of setting pending: false - rejectModule: Remove action from moduleActions instead of setting pending: false - checkAndClearSnapshot: Check if moduleActions is empty instead of checking pending states - Fix typo: getModuleFromFrom → getModuleFromFlow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * cleaning * fix logic * make diff drawer part of manager * accept submodules * fixes * Phase 4: Add checkAndApplyChanges() helper to flowDiffManager - Added new checkAndApplyChanges() function to apply mergedFlow to flowStore when all changes are decided - This replaces the old checkAndClearSnapshot() behavior and ensures flowStore is updated atomically - Handles both flow structure and input schema updates * Phase 2: Simplify acceptModule() - only modify mergedFlow - Remove flowStore mutations from acceptModule() - For removed modules: just delete the shadowed (__prefix) version from mergedFlow - For added/modified: no action needed (already correct in mergedFlow) - Call checkAndApplyChanges() to apply changes when all decided * Phase 3: Simplify rejectModule() - only modify mergedFlow - Remove all flowStore mutations from rejectModule() - For added modules: delete from mergedFlow - For removed modules: replace shadowed (__) module with original from beforeFlow - For modified modules: restore old version in mergedFlow - For Input schema: revert afterInputSchema - Call checkAndApplyChanges() to apply changes when all decided * Phase 5: Verify acceptAll/rejectAll work with new architecture - acceptAll() and rejectAll() already pass options correctly to acceptModule/rejectModule - They will automatically benefit from checkAndApplyChanges() - No changes needed for this phase * Phase 6: Remove FlowGraphV2 reactive effect that updates afterFlow - Removed the (lines 252-266) that continuously updated afterFlow - This effect created reactive loops when flowStore changed - afterFlow should only be set once when AI generates changes via setFlowYaml() - The initial sync effect (lines 226-250) is kept for prop-driven diff mode * Phase 7: Update FlowAIChat setFlowYaml to use diffManager - Changed setFlowYaml() to use diffManager.setAfterFlow() instead of modifying flowStore - flowStore remains unchanged during AI review phase - Changes are staged in mergedFlow for user review - Only applied to flowStore when all changes are accepted/rejected - Added error handling for missing diffManager * Fix linter warnings - Remove unused FlowTimeline type import - Fix ChangeTracker initialization with proper type parameter - Keep deleteModuleFromFlow and checkAndClearSnapshot for potential future use * Update plan document with implementation status - Mark all phases as complete - Add commit references - Update file checklist - Add implementation summary at top of document * Add comprehensive implementation summary document - Detailed overview of architecture changes - Before/after comparisons for each file - Complete testing scenarios checklist - Troubleshooting guide - Migration notes and backwards compatibility info * Show pending modules in editor panel - Pass diffManager from FlowModuleSchemaMap to FlowEditorPanel - Add effectiveModules derived value that uses mergedFlow when in diff mode - Update module iteration to use effectiveModules instead of flowStore - Allows users to view added/modified modules during AI review - Fixes issue where clicking on pending modules showed nothing * Add implementation summary for show pending modules feature * fix * shorter system prompt * Fix Input schema diff mode issues - Add Accept/Reject buttons to Input node (previously only showed Diff button) - Pass diffManager to FlowInput component - Add effectiveSchema derived value that uses afterInputSchema when in diff mode - Add effectiveDisabled to prevent editing Input when reviewing AI changes - Update FlowInputViewer to show pending schema changes - Fixes issue where Input schema changes couldn't be accepted/rejected - Fixes issue where pending Input schema wasn't visible in the panel * Disable delete and move buttons when in pending mode - Add effectiveDeletable derived value that checks diffManager.hasPendingChanges - Replace all instances of deletable with effectiveDeletable in template - Prevents delete/move operations when AI changes are being reviewed - Delete and move buttons are hidden when there are pending changes - Buttons reappear once all changes are accepted or rejected - Prevents conflicting operations during review phase * no move or delte when reviewing * use context * inline script reduction * use json * rollback to direct modif * fix merge * cleaning * fix reject removed * add set step code tool * better prompt * add back relevant tools * add back accept reject * use edit mode for pending * fix input * remove unneeded effect * cleaner + bug fix * fix failure and preprocessor * fix show diff for failure module * fix accept reject on failre module * no auto add module to context * cleaning * add back effect * cleaning * fix multiple setflowjson * track effectivemoduleactions for graph rendering * nit prompt * styling * rm md files * rm flake copy * cleaning * fix z index * fix revert * only change before after * use add remove modify tools * input + failure + preproc tools * parsing issues * nit * use raw schema for tools * resolve ref for gemini * fix schema * show test on graph * much cleaner logic * ignore empty assets * Remove debug console.log statements from production code 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Remove debug $inspect calls from FlowGraphV2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Add error logging to setFlowJson before re-throwing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Standardize null/undefined handling to prefer null - Use .nullable().optional() instead of .nullish() in Zod schemas - Simplify addModuleToFlow signature to use string | null - Coerce undefined to null when extracting parsed args - Simplify null checks to only check !== null 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Remove debug console.log from AI tool functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Extract special module IDs to constants Add SPECIAL_MODULE_IDS constant with INPUT, PREPROCESSOR, and FAILURE to avoid magic strings throughout the flow AI chat code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Add cleanup for diffDrawer reference on unmount Prevents potential memory leaks by clearing the diffDrawer reference when the FlowGraphV2 component is destroyed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Use structuredClone instead of JSON.parse(JSON.stringify()) structuredClone is more efficient and type-safe for deep cloning objects. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Cache module lookups in reconstructMergedFlow Move getAllModulesMap and getAllModuleIds calls outside the loop to avoid redundant recomputation. Track merged IDs incrementally as modules are added. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Revert "Use structuredClone instead of JSON.parse(JSON.stringify())" This reverts commit a62ba5b9807a739b272f79ff661e0491a39b11e0. * cleaning * allow delete * better openflow for ai agents + truncate system prompt * handle ai agent tools * fix set code for tool * fix wrong cancel request called * mark tool calls as canceled * get lang instructions * use streamiing args * give db url to claude * fix revert * save and clear when leaving editor * keep whitespace in user message * uniformize colors * fix diff button * remove db from backend claude * remove move module tool * no failure and preprocessor * fix error given to llm * fix z index * fix ts errors * cleaning * fix add module logic * fix(copilot): add 'tools' to branchPath description for aiagent containers The branchPath parameter description was missing 'tools' option for aiagent containers and didn't mention branchall support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(copilot): correct AI agent tool IDs and summaries documentation Tool summaries CAN contain spaces (they're human-readable descriptions). Only tool IDs must avoid spaces. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(copilot): remove reference to non-existent set_flow_json tool The set_module_code tool description referenced set_flow_json which doesn't exist as an exposed tool (it's an internal helper). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(copilot): clarify inspect_inline_script is read-only The tool description incorrectly suggested it could modify code. This tool only inspects - use set_module_code to modify. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(copilot): clarify afterId behavior for AI agent tools Updated wording to clarify that afterId can be used but is optional for AI agent tools since tool order doesn't affect execution. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor(copilot): remove unused id param from get_instructions_for_code_generation The id parameter was only used to check for preprocessor, which is no longer needed. Simplified the tool to only require the language param. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * docs(copilot): add result format to search_scripts tool description Helps AI understand what data format to expect from the tool. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * docs(copilot): add result format to resource_type tool description Helps AI understand what data format to expect from the tool and provides example resource type names. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * nit * Add support for adding branches to branchall/branchone via add_module Previously, add_module could only add modules inside existing branches. Now, using insideId with branchPath=null will add a NEW branch to a branchall or branchone container. API: - add_module({ insideId: "my_branchall", branchPath: null, value: { summary: "New Branch", skip_failure: false, modules: [] } }) - add_module({ insideId: "my_branchone", branchPath: null, value: { summary: "Condition", expr: "...", modules: [] } }) Changes: - Extended addModuleToFlow to handle branchPath=null case - Updated validation to allow branchPath=null when adding branches - Updated tool descriptions and system prompt documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * nit * add remove branch tool * check all ids for duplicates * no dup * nit * cleaning * fix dup ids * split core.ts * only mount diff drawer if useful * remove wrong logic * update exprs * fix * chore(flow): Add unit tests to flow diff manager (#7291) * setup * add basic tests for flowdiff * add complex tests * fix branch issue * more complex tests * add flow diff manager tests * add utils * better handling of moved case * more tests for move case * add buggy test case * rm * rework config * cleaning * fix config * rm * fix for reverting type change module * all good * rm * add missing testmode --------- Co-authored-by: Claude --- CLAUDE.md | 14 + backend/CLAUDE.md | 13 - frontend/minifiedOpenflowJson.sh | 52 + frontend/package-lock.json | 531 +++++- frontend/package.json | 9 +- .../src/lib/components/FlowBuilder.svelte | 7 +- .../lib/components/FlowGraphDiffViewer.svelte | 161 +- .../lib/components/FlowPreviewContent.svelte | 10 +- frontend/src/lib/components/ModuleTest.svelte | 4 +- .../src/lib/components/ScriptEditor.svelte | 1 + .../copilot/chat/AIChatDisplay.svelte | 6 +- .../copilot/chat/AIChatManager.svelte.ts | 19 +- .../copilot/chat/AIChatMessage.svelte | 2 +- .../copilot/chat/ToolExecutionDisplay.svelte | 4 +- .../copilot/chat/flow/FlowAIChat.svelte | 766 ++------ .../chat/flow/ModuleAcceptReject.svelte | 63 - .../lib/components/copilot/chat/flow/core.ts | 1559 +++++++++------- .../copilot/chat/flow/inlineScriptsUtils.ts | 284 +++ .../copilot/chat/flow/openFlow.json | 1 + .../lib/components/copilot/chat/flow/utils.ts | 782 +++++++- .../src/lib/components/copilot/chat/shared.ts | 105 +- frontend/src/lib/components/copilot/lib.ts | 26 +- .../lib/components/flows/FlowEditor.svelte | 4 +- .../flows/content/FlowEditorPanel.svelte | 6 +- .../components/flows/content/FlowInput.svelte | 44 +- .../src/lib/components/flows/flowDiff.test.ts | 1519 ++++++++++++++++ .../components/flows/flowDiff.testUtils.ts | 166 ++ frontend/src/lib/components/flows/flowDiff.ts | 496 ++++- .../flows/flowDiffManager.svelte.test.ts | 1619 +++++++++++++++++ .../flows/flowDiffManager.svelte.ts | 543 ++++++ .../src/lib/components/flows/flowExplorer.ts | 17 + .../components/flows/flowStateUtils.svelte.ts | 10 +- .../flows/header/FlowImportExportMenu.svelte | 3 +- .../flows/header/FlowPreviewButtons.svelte | 4 +- .../components/flows/map/DiffActionBar.svelte | 64 + .../flows/map/FlowErrorHandlerItem.svelte | 86 +- .../flows/map/FlowModuleSchemaItem.svelte | 66 +- .../flows/map/FlowModuleSchemaMap.svelte | 8 + .../flows/map/FlowStickyNode.svelte | 7 +- .../lib/components/flows/map/MapItem.svelte | 15 +- .../components/flows/map/VirtualItem.svelte | 17 +- .../lib/components/graph/FlowGraphV2.svelte | 99 +- .../components/graph/graphBuilder.svelte.ts | 18 +- .../src/lib/components/graph/graphContext.ts | 2 + .../graph/renderers/nodes/InputNode.svelte | 29 +- .../graph/renderers/nodes/ModuleNode.svelte | 1 - frontend/src/lib/components/graph/util.ts | 86 +- frontend/vite.config.js | 47 +- openflow.openapi.yaml | 162 +- 49 files changed, 7794 insertions(+), 1763 deletions(-) create mode 100755 frontend/minifiedOpenflowJson.sh delete mode 100644 frontend/src/lib/components/copilot/chat/flow/ModuleAcceptReject.svelte create mode 100644 frontend/src/lib/components/copilot/chat/flow/inlineScriptsUtils.ts create mode 100644 frontend/src/lib/components/copilot/chat/flow/openFlow.json create mode 100644 frontend/src/lib/components/flows/flowDiff.test.ts create mode 100644 frontend/src/lib/components/flows/flowDiff.testUtils.ts create mode 100644 frontend/src/lib/components/flows/flowDiffManager.svelte.test.ts create mode 100644 frontend/src/lib/components/flows/flowDiffManager.svelte.ts create mode 100644 frontend/src/lib/components/flows/map/DiffActionBar.svelte diff --git a/CLAUDE.md b/CLAUDE.md index 3f29bf04ab..9f68fae3a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,3 +19,17 @@ When implementing new features in Windmill, follow these best practices: - Backend (Rust): @backend/rust-best-practices.mdc + @backend/summarized_schema.txt - Frontend (Svelte 5): @frontend/svelte5-best-practices.mdc + +## Querying the Database + +To query the database directly, use psql with the following connection string: + +```bash +psql postgres://postgres:changeme@localhost:5432/windmill +``` + +This can be helpful for: + +- Inspecting database state during development +- Testing queries before implementing them in Rust +- Debugging data-related issues diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 78faa0bf77..1fe509a0d6 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -10,16 +10,3 @@ 1. Update database schema with migration if necessary 2. Update backend/windmill-api/openapi.yaml after modifying API endpoints - -## Querying the Database - -To query the database directly, use psql with the following connection string: - -```bash -psql postgres://postgres:changeme@localhost:5432/windmill -``` - -This can be helpful for: -- Inspecting database state during development -- Testing queries before implementing them in Rust -- Debugging data-related issues diff --git a/frontend/minifiedOpenflowJson.sh b/frontend/minifiedOpenflowJson.sh new file mode 100755 index 0000000000..71f51067c3 --- /dev/null +++ b/frontend/minifiedOpenflowJson.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +set -e + +# Script to generate minified OpenFlow JSON for frontend AI system prompt +script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source_file="${script_dirpath}/../windmill-yaml-validator/src/gen/openflow.json" +output_dirpath="${script_dirpath}/src/lib/components/copilot/chat/flow" +output_file="${output_dirpath}/openFlow.json" + +echo "Generating minified OpenFlow JSON..." + +# Validate source file exists +if [ ! -f "${source_file}" ]; then + echo "Error: Source file not found: ${source_file}" + echo "Please run windmill-yaml-validator/gen_openflow_schema.sh first" + exit 1 +fi + +# Create output directory if it doesn't exist +mkdir -p "${output_dirpath}" + +# Minify JSON by removing all whitespace +node -e " +const fs = require('fs'); +const sourceFile = '${source_file}'; +const outputFile = '${output_file}'; + +try { + const schema = JSON.parse(fs.readFileSync(sourceFile, 'utf8')); + + // Minify: stringify without spaces + const minified = JSON.stringify(schema); + + fs.writeFileSync(outputFile, minified); + + const originalSize = fs.statSync(sourceFile).size; + const minifiedSize = fs.statSync(outputFile).size; + const savings = ((originalSize - minifiedSize) / originalSize * 100).toFixed(1); + + console.log(' Minified OpenFlow JSON generated successfully'); + console.log(' Original size: ' + (originalSize / 1024).toFixed(1) + ' KB'); + console.log(' Minified size: ' + (minifiedSize / 1024).toFixed(1) + ' KB'); + console.log(' Savings: ' + savings + '%'); + console.log(' Output: ' + outputFile); +} catch (e) { + console.error('Error minifying JSON:', e.message); + process.exit(1); +} +" + +echo "Done!" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 717eb3c627..be9801e09f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -114,6 +114,7 @@ "@types/vscode": "^1.83.5", "@typescript-eslint/eslint-plugin": "^5.59.8", "@typescript-eslint/parser": "^5.60.0", + "@vitest/browser-playwright": "^4.0.10", "@zerodevx/svelte-toast": "^0.9.6", "autoprefixer": "^10.4.13", "cssnano": "^6.0.1", @@ -123,6 +124,7 @@ "eslint-config-prettier": "^8.6.0", "eslint-plugin-svelte": "^2.45.1", "path-browserify": "^1.0.1", + "playwright": "^1.56.1", "postcss": "^8.4.49", "postcss-load-config": "^4.0.1", "prettier": "^3.1.0", @@ -145,6 +147,8 @@ "typescript": "^5.5.0", "vite": "npm:rolldown-vite@7.2.8", "vite-plugin-mkcert": "^1.17.5", + "vitest": "^4.0.10", + "vitest-browser-svelte": "^2.0.1", "yootils": "^0.3.1" }, "optionalDependencies": { @@ -2481,6 +2485,53 @@ "node": ">=18" } }, + "node_modules/@playwright/test/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/@playwright/test/node_modules/playwright": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", + "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/@playwright/test/node_modules/playwright-core": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", + "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -3046,6 +3097,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -3340,6 +3402,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/diff": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", @@ -3649,6 +3718,178 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@vitest/browser": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.15.tgz", + "integrity": "sha512-zedtczX688KehaIaAv7m25CeDLb0gBtAOa2Oi1G1cqvSO5aLSVfH6lpZMJLW8BKYuWMxLQc9/5GYoM+jgvGIrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/mocker": "4.0.15", + "@vitest/utils": "4.0.15", + "magic-string": "^0.30.21", + "pixelmatch": "7.1.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.18.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.15" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.15.tgz", + "integrity": "sha512-94yVpDbb+ykiT7mK6ToonGnq2GIHEQGBTZTAzGxBGQXcVNCh54YKC2/WkfaDzxy0m6Kgw05kq3FYHKHu+wRdIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.0.15", + "@vitest/mocker": "4.0.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.0.15" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", + "integrity": "sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz", + "integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.15", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz", + "integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.15.tgz", + "integrity": "sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.15", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.15.tgz", + "integrity": "sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.15.tgz", + "integrity": "sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.15.tgz", + "integrity": "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vscode/iconv-lite-umd": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.0.tgz", @@ -3958,6 +4199,16 @@ "node": ">=0.10.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -4497,6 +4748,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5717,6 +5978,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -6197,6 +6465,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -8304,9 +8582,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -9766,6 +10044,17 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ohash": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz", @@ -10188,6 +10477,19 @@ "node": ">= 6" } }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -10208,13 +10510,13 @@ "license": "MIT" }, "node_modules/playwright": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", - "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.0" + "playwright-core": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -10227,9 +10529,9 @@ } }, "node_modules/playwright-core": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", - "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -10263,6 +10565,16 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -11842,6 +12154,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -12044,6 +12363,20 @@ "license": "CC0-1.0", "peer": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -13045,6 +13378,13 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -13100,6 +13440,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -13652,6 +14002,128 @@ } } }, + "node_modules/vitest": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", + "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.15", + "@vitest/mocker": "4.0.15", + "@vitest/pretty-format": "4.0.15", + "@vitest/runner": "4.0.15", + "@vitest/snapshot": "4.0.15", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.15", + "@vitest/browser-preview": "4.0.15", + "@vitest/browser-webdriverio": "4.0.15", + "@vitest/ui": "4.0.15", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest-browser-svelte": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vitest-browser-svelte/-/vitest-browser-svelte-2.0.1.tgz", + "integrity": "sha512-z7GFio7vxaOolY+xwPUMEKuwL4KcPzB8+bepA9F0Phqag/TJ4j7IAGSwm4Y/FBh7KznsP+7aEIllMay0qDpFXw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vitest": "^4.0.0" + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/vscode": { "name": "@codingame/monaco-vscode-extension-api", "version": "21.6.0", @@ -13825,6 +14297,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/windmill-parser-wasm-csharp": { "version": "1.510.1", "resolved": "https://registry.npmjs.org/windmill-parser-wasm-csharp/-/windmill-parser-wasm-csharp-1.510.1.tgz", @@ -14033,6 +14522,28 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-utils": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index c60082b434..2c43097b83 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,8 +15,9 @@ "generate-backend-client": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript --format false", "generate-backend-client-mac": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript", "pretest": "tsc --incremental -p tests/tsconfig.json", - "test": "playwright test --config=tests-out/playwright.config.js", - "filter-classes": "node filterTailwindClasses.js" + "test": "playwright test --config=tests-out/playwright.config.js && npm run test:unit -- --run", + "filter-classes": "node filterTailwindClasses.js", + "test:unit": "vitest" }, "devDependencies": { "@floating-ui/core": "^1.3.1", @@ -37,6 +38,7 @@ "@types/vscode": "^1.83.5", "@typescript-eslint/eslint-plugin": "^5.59.8", "@typescript-eslint/parser": "^5.60.0", + "@vitest/browser-playwright": "^4.0.10", "@zerodevx/svelte-toast": "^0.9.6", "autoprefixer": "^10.4.13", "cssnano": "^6.0.1", @@ -46,6 +48,7 @@ "eslint-config-prettier": "^8.6.0", "eslint-plugin-svelte": "^2.45.1", "path-browserify": "^1.0.1", + "playwright": "^1.56.1", "postcss": "^8.4.49", "postcss-load-config": "^4.0.1", "prettier": "^3.1.0", @@ -68,6 +71,8 @@ "typescript": "^5.5.0", "vite": "npm:rolldown-vite@7.2.8", "vite-plugin-mkcert": "^1.17.5", + "vitest": "^4.0.10", + "vitest-browser-svelte": "^2.0.1", "yootils": "^0.3.1" }, "overrides": { diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index f16cd551cc..6fb4ef7a12 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -163,7 +163,7 @@ } function hasAIChanges(): boolean { - return aiChatManager.flowAiChatHelpers?.hasDiff() ?? false + return aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false } function withAIChangesWarning(callback: () => void) { @@ -896,7 +896,7 @@ initialPath && initialPath != '' && $workspaceStore && untrack(() => loadTriggers()) }) $effect.pre(() => { - const hasAiDiff = aiChatManager.flowAiChatHelpers?.hasDiff() ?? false + const hasAiDiff = aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false customUi && untrack(() => onCustomUiChange(customUi, hasAiDiff)) }) @@ -1123,8 +1123,7 @@ ) diffDrawer?.openDrawer() - const currentFlow = - aiChatManager.flowAiChatHelpers?.getPreviewFlow() ?? flowStore.val + const currentFlow = flowStore.val diffDrawer?.setDiff({ mode: 'normal', deployed: deployedValue ?? savedFlow, diff --git a/frontend/src/lib/components/FlowGraphDiffViewer.svelte b/frontend/src/lib/components/FlowGraphDiffViewer.svelte index 8331d9dc06..f66a582eb5 100644 --- a/frontend/src/lib/components/FlowGraphDiffViewer.svelte +++ b/frontend/src/lib/components/FlowGraphDiffViewer.svelte @@ -1,11 +1,9 @@ - - diff --git a/frontend/src/lib/components/copilot/chat/flow/ModuleAcceptReject.svelte b/frontend/src/lib/components/copilot/chat/flow/ModuleAcceptReject.svelte deleted file mode 100644 index 27209bfbf4..0000000000 --- a/frontend/src/lib/components/copilot/chat/flow/ModuleAcceptReject.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - - - -{#if action && id} -
- {#if action === 'modified'} - - {/if} -
- - -
-
-{/if} diff --git a/frontend/src/lib/components/copilot/chat/flow/core.ts b/frontend/src/lib/components/copilot/chat/flow/core.ts index 8fd4233570..5520370917 100644 --- a/frontend/src/lib/components/copilot/chat/flow/core.ts +++ b/frontend/src/lib/components/copilot/chat/flow/core.ts @@ -1,18 +1,24 @@ -import { ScriptService, type FlowModule, type RawScript, type Script, JobService } from '$lib/gen' -import { emitUiIntent } from './uiIntents' +import { + ScriptService, + type FlowModule, + type InputTransform, + type RawScript, + type Script, + JobService +} from '$lib/gen' import type { ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam } from 'openai/resources/chat/completions.mjs' -import YAML from 'yaml' +import type { ChatCompletionTool as ChatCompletionFunctionTool } from 'openai/resources/chat/completions.mjs' import { z } from 'zod' import uFuzzy from '@leeoniya/ufuzzy' -import { emptySchema, emptyString } from '$lib/utils' +import { emptyString } from '$lib/utils' import { + createDbSchemaTool, getFormattedResourceTypes, getLangContext, - SUPPORTED_CHAT_SCRIPT_LANGUAGES, - createDbSchemaTool + SUPPORTED_CHAT_SCRIPT_LANGUAGES } from '../script/core' import { createSearchHubScriptsTool, @@ -23,59 +29,59 @@ import { buildTestRunArgs, buildContextString, applyCodePiecesToFlowModules, - findModuleById + findModuleById, + SPECIAL_MODULE_IDS } from '../shared' import type { ContextElement } from '../context' import type { ExtendedOpenFlow } from '$lib/components/flows/types' +import openFlowSchema from './openFlow.json' +import { + resolveSchemaRefs, + collectAllModuleIds, + findModuleInFlow, + addModuleToFlow, + removeModuleFromFlow, + removeBranchFromFlow, + replaceModuleInFlow +} from './utils' +import { inlineScriptStore, extractAndReplaceInlineScripts } from './inlineScriptsUtils' -export type AIModuleAction = 'added' | 'modified' | 'removed' | 'shadowed' | undefined - +/** + * Helper interface for AI chat flow operations + * + * Note: AI chat is only responsible for setting the beforeFlow snapshot when making changes. + * Accept/reject operations are exposed here but implemented via flowDiffManager. + */ export interface FlowAIChatHelpers { // flow context getFlowAndSelectedId: () => { flow: ExtendedOpenFlow; selectedId: string } - // flow apply/reject - getPreviewFlow: () => ExtendedOpenFlow - hasDiff: () => boolean - setLastSnapshot: (snapshot: ExtendedOpenFlow) => void - showModuleDiff: (id: string) => void - getModuleAction: (id: string) => AIModuleAction | undefined - revertModuleAction: (id: string) => void - acceptModuleAction: (id: string) => void - acceptAllModuleActions: () => void - rejectAllModuleActions: () => void - revertToSnapshot: (snapshot?: ExtendedOpenFlow) => void - // ai chat tools - insertStep: (location: InsertLocation, step: NewStep) => Promise - removeStep: (id: string) => void - getStepInputs: (id: string) => Promise> - setStepInputs: (id: string, inputs: string) => Promise - getFlowInputsSchema: () => Promise> - setFlowInputsSchema: (inputs: Record) => Promise - selectStep: (id: string) => void - getStepCode: (id: string) => string getModules: (id?: string) => FlowModule[] - setBranchPredicate: (id: string, branchIndex: number, expression: string) => Promise - addBranch: (id: string) => Promise - removeBranch: (id: string, branchIndex: number) => Promise - setForLoopIteratorExpression: (id: string, expression: string) => Promise - setForLoopOptions: ( - id: string, - opts: { - skip_failures?: boolean | null - parallel?: boolean | null - parallelism?: number | null - } - ) => Promise - setModuleControlOptions: ( - id: string, - opts: { - stop_after_if?: boolean | null - stop_after_if_expr?: string | null - skip_if?: boolean | null - skip_if_expr?: string | null - } - ) => Promise + + // snapshot management (AI sets this when making changes) + /** Set the before flow snapshot */ + setSnapshot: (snapshot: ExtendedOpenFlow) => void + /** Revert the entire flow to a previous snapshot */ + revertToSnapshot: (snapshot?: ExtendedOpenFlow) => void + + // ai chat tools setCode: (id: string, code: string) => Promise + setFlowJson: (json: string) => Promise + getFlowInputsSchema: () => Promise> + /** Update exprsToSet store for InputTransformForm components (only if module is selected) */ + updateExprsToSet: (id: string, inputTransforms: Record) => void + + // accept/reject operations (via flowDiffManager) + /** Accept all pending module changes */ + acceptAllModuleActions: () => void + /** Reject all pending module changes */ + rejectAllModuleActions: () => void + /** Check if there are pending changes requiring user approval */ + hasPendingChanges: () => boolean + /** Select a step in the flow */ + selectStep: (id: string) => void + + /** Run a test of the current flow using the UI's preview mechanism */ + testFlow: (args?: Record, conversationId?: string) => Promise } const searchScriptsSchema = z.object({ @@ -87,281 +93,143 @@ const searchScriptsSchema = z.object({ const searchScriptsToolDef = createToolDef( searchScriptsSchema, 'search_scripts', - 'Search for scripts in the workspace' + 'Search for scripts in the workspace. Returns array of {path, summary} objects.' ) const langSchema = z.enum( SUPPORTED_CHAT_SCRIPT_LANGUAGES as [RawScript['language'], ...RawScript['language'][]] ) -const newStepSchema = z.union([ - z - .object({ - type: z.literal('rawscript'), - language: langSchema.describe( - 'The language to use for the code, default to bun if none specified' - ), - summary: z.string().describe('The summary of what the step does, in 3-5 words') - }) - .describe('Add a raw script step at the specified location'), - z - .object({ - type: z.literal('script'), - path: z.string().describe('The path of the script to use for the step.') - }) - .describe('Add a script step at the specified location'), - z - .object({ - type: z.literal('forloop') - }) - .describe('Add a for loop at the specified location'), - z - .object({ - type: z.literal('branchall') - }) - .describe('Add a branch all at the specified location: all branches will be executed'), - z - .object({ - type: z.literal('branchone') - }) - .describe( - 'Add a branch one at the specified location: only the first branch that evaluates to true will be executed' - ) -]) - -type NewStep = z.infer - -const insertLocationSchema = z.union([ - z - .object({ - type: z.literal('after'), - afterId: z.string().describe('The id of the step after which the new step will be added.') - }) - .describe('Add a step after the given step id'), - z - .object({ - type: z.literal('start') - }) - .describe('Add a step at the start of the flow'), - z - .object({ - type: z.literal('start_inside_forloop'), - inside: z - .string() - .describe('The id of the step inside which the new step will be added (forloop step only)') - }) - .describe('Add a step at the start of the given step (forloop step only)'), - z - .object({ - type: z.literal('start_inside_branch'), - inside: z - .string() - .describe( - 'The id of the step inside which the new step will be added (branchone or branchall only).' - ), - branchIndex: z - .number() - .describe( - 'The index of the branch inside the forloop step, starting at 0. For the default branch (branchone only), the branch index is -1.' - ) - }) - .describe( - 'Add a step at the start of a given branch of the given step (branchone or branchall only)' - ), - z - .object({ - type: z.literal('preprocessor') - }) - .describe('Insert a preprocessor step (runs before the first step when triggered externally)'), - z - .object({ - type: z.literal('failure') - }) - .describe('Insert a failure step (only executed when the flow fails)') -]) - -type InsertLocation = z.infer - -const addStepSchema = z.object({ - location: insertLocationSchema, - step: newStepSchema +const resourceTypeToolSchema = z.object({ + query: z.string().describe('The query to search for, e.g. stripe, google, etc..'), + language: langSchema.describe( + 'The programming language the code using the resource type will be written in' + ) }) -const addStepToolDef = createToolDef( - addStepSchema, - 'add_step', - 'Add a step at the specified location' +const resourceTypeToolDef = createToolDef( + resourceTypeToolSchema, + 'resource_type', + 'Search for resource types (e.g., postgresql, stripe). Returns formatted resource type definitions with usage examples.' ) -const removeStepSchema = z.object({ - id: z.string().describe('The id of the step to remove') +const getInstructionsForCodeGenerationToolSchema = z.object({ + language: langSchema.describe('The programming language the code will be written in') }) -const removeStepToolDef = createToolDef( - removeStepSchema, - 'remove_step', - 'Remove the step with the given id' +const getInstructionsForCodeGenerationToolDef = createToolDef( + getInstructionsForCodeGenerationToolSchema, + 'get_instructions_for_code_generation', + 'Get instructions for code generation for a raw script step' ) -const setForLoopIteratorExpressionSchema = z.object({ - id: z.string().describe('The id of the forloop step to set the iterator expression for'), - expression: z.string().describe('The JavaScript expression to set for the iterator') +const addModuleToolDef: ChatCompletionFunctionTool = { + type: 'function', + function: { + strict: false, + name: 'add_module', + description: + "Add a new module to the flow. Use afterId to insert after a specific module (null to insert at the beginning), or insideId+branchPath to insert into branches/loops. Note: The IDs 'failure', 'preprocessor', and 'Input' are reserved and cannot be used.", + parameters: { + type: 'object', + properties: { + afterId: { + type: ['string', 'null'], + description: + 'ID of the module to insert after. Use null to insert at the beginning. Can be used with insideId+branchPath to specify position within a container.' + }, + insideId: { + type: ['string', 'null'], + description: + 'ID of the container module (branch/loop/branchall/branchone) to insert into. Use with branchPath to add a module inside a container, or with branchPath=null to add a new branch to branchall/branchone.' + }, + branchPath: { + type: ['string', 'null'], + description: + "Path to insert a module inside a container: 'modules' (for loops), 'branches.0'/'branches.1'/etc (to add inside a specific branch), 'default' (for branchone default branch), or 'tools' (for aiagent). Use null with insideId pointing to a branchall/branchone to add a NEW branch (value should be a branch object with summary, modules, etc.)." + }, + value: { + ...resolveSchemaRefs(openFlowSchema.components.schemas.FlowModule, openFlowSchema), + description: 'Complete module object including id, summary, and value fields' + } + }, + required: ['value'] + } + } +} + +const removeModuleSchema = z.object({ + id: z.string().describe('ID of the module to remove') }) -const setForLoopIteratorExpressionToolDef = createToolDef( - setForLoopIteratorExpressionSchema, - 'set_forloop_iterator_expression', - 'Set the iterator JavaScript expression for the given forloop step' -) - -const setForLoopOptionsSchema = z.object({ - id: z.string().describe('The id of the forloop step to configure'), - skip_failures: z - .boolean() - .nullable() - .optional() - .describe('Whether to skip failures in the loop (null to not change)'), - parallel: z - .boolean() - .nullable() - .optional() - .describe('Whether to run iterations in parallel (null to not change)'), - parallelism: z - .number() - .int() - .min(1) - .nullable() - .optional() - .describe('Maximum number of parallel iterations (null to not change)') -}) - -const setForLoopOptionsToolDef = createToolDef( - setForLoopOptionsSchema, - 'set_forloop_options', - 'Set advanced options for a forloop step: skip_failures, parallel, and parallelism' -) - -const setModuleControlOptionsSchema = z.object({ - id: z.string().describe('The id of the module to configure'), - stop_after_if: z - .boolean() - .nullable() - .optional() - .describe('Early stop condition (true to set, false to clear, null to not change)'), - stop_after_if_expr: z - .string() - .nullable() - .optional() - .describe( - 'JavaScript expression for early stop condition. Can use `flow_input` or `result`. `result` is the result of the step. `results.` is not supported, do not use it. Only used if stop_after_if is true. Example: `flow_input.x > 10` or `result === "failure"`' - ), - skip_if: z - .boolean() - .nullable() - .optional() - .describe('Skip condition (true to set, false to clear, null to not change)'), - skip_if_expr: z - .string() - .nullable() - .optional() - .describe( - 'JavaScript expression for skip condition. Can use `flow_input` or `results.`. Only used if skip_if is true. Example: `flow_input.x > 10` or `results.a === "failure"`' - ) -}) - -const setModuleControlOptionsToolDef = createToolDef( - setModuleControlOptionsSchema, - 'set_module_control_options', - 'Set control options for any module: stop_after_if (early stop) and skip_if (conditional skip)' -) - -const setBranchPredicateSchema = z.object({ - id: z.string().describe('The id of the branchone step to set the predicates for'), - branchIndex: z - .number() - .describe('The index of the branch to set the predicate for, starting at 0.'), - expression: z.string().describe('The JavaScript expression to set for the predicate') -}) -const setBranchPredicateToolDef = createToolDef( - setBranchPredicateSchema, - 'set_branch_predicate', - 'Set the predicates using a JavaScript expression for the given branch, only applicable for branchone branches.' -) - -const addBranchSchema = z.object({ - id: z.string().describe('The id of the step to add the branch to') -}) -const addBranchToolDef = createToolDef( - addBranchSchema, - 'add_branch', - 'Add a branch to the given step, applicable to branchall and branchone steps' +const removeModuleToolDef = createToolDef( + removeModuleSchema, + 'remove_module', + "Remove a module from the flow by its ID. Searches recursively through all nested structures. Note: The IDs 'failure', 'preprocessor', and 'Input' are reserved and cannot be removed." ) const removeBranchSchema = z.object({ - id: z.string().describe('The id of the step to remove the branch from'), - branchIndex: z.number().describe('The index of the branch to remove, starting at 0') + insideId: z.string().describe('ID of the branchall/branchone container'), + branchIndex: z.number().int().min(0).describe('Index of the branch to remove (0-based)') }) + const removeBranchToolDef = createToolDef( removeBranchSchema, 'remove_branch', - 'Remove the branch with the given index from the given step, applicable to branchall and branchone steps.' + 'Remove a branch from a branchall/branchone by its index. Use this to delete an entire branch including all modules inside it.' ) -const getStepInputsSchema = z.object({ - id: z.string().describe('The id of the step to get the inputs for') -}) +const modifyModuleToolDef: ChatCompletionFunctionTool = { + type: 'function', + function: { + strict: false, + name: 'modify_module', + description: + "Modify an existing module (full replacement). Use for changing configuration, transforms, or conditions. Not for adding/removing nested modules. Note: The IDs 'failure', 'preprocessor', and 'Input' are reserved and cannot be modified.", + parameters: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'ID of the module to modify' + }, + value: { + ...resolveSchemaRefs(openFlowSchema.components.schemas.FlowModule, openFlowSchema), + description: + 'Complete new module object (full replacement). Use this to change module configuration, input_transforms, branch conditions, etc. Do NOT use this to add/remove modules inside branches/loops - use add_module/remove_module for that.' + } + }, + required: ['id', 'value'] + } + } +} -const getStepInputsToolDef = createToolDef( - getStepInputsSchema, - 'get_step_inputs', - 'Get the inputs for the given step id' -) +const setFlowSchemaToolDef: ChatCompletionFunctionTool = { + type: 'function', + function: { + strict: false, + name: 'set_flow_schema', + description: + 'Set or update the flow input schema. Defines what parameters the flow accepts when executed.', + parameters: { + type: 'object', + properties: { + schema: { + type: 'object', + description: 'Flow input schema defining the parameters the flow accepts' + } + }, + required: ['schema'] + } + } +} -const setStepInputsSchema = z.object({ - id: z.string().describe('The id of the step to set the inputs for'), - inputs: z.string().describe('The inputs to set for the step') -}) +/** Restricted module IDs that cannot be used in add/modify/remove operations */ +const RESTRICTED_MODULE_IDS = Object.values(SPECIAL_MODULE_IDS) -const setStepInputsToolDef = createToolDef( - setStepInputsSchema, - 'set_step_inputs', - `Set all inputs for the given step id. - -Return a list of input. Each input should be defined by its input name enclosed in double square brackets ([[inputName]]), followed by a JavaScript expression that sets its value. -The value expression can span multiple lines. Separate each input block with a blank line. - -Example: - -[[input1]] -\`Hello, \${results.a}\` - -[[input2]] -flow_input.iter.value - -[[input3]] -flow_input.x` -) - -const setFlowInputsSchemaSchema = z.object({ - schema: z.string().describe('JSON string of the flow inputs schema (draft 2020-12)') -}) - -const setFlowInputsSchemaToolDef = createToolDef( - setFlowInputsSchemaSchema, - 'set_flow_inputs_schema', - 'Set the flow inputs schema. **Overrides the current schema.**' -) - -const setCodeSchema = z.object({ - id: z.string().describe('The id of the step to set the code for'), - code: z.string().describe('The code to apply') -}) - -const setCodeToolDef = createToolDef( - setCodeSchema, - 'set_code', - 'Set the code for the current step.' -) +function isRestrictedModuleId(id: string): boolean { + return RESTRICTED_MODULE_IDS.includes(id as (typeof RESTRICTED_MODULE_IDS)[number]) +} class WorkspaceScriptsSearch { private uf: uFuzzy @@ -374,7 +242,7 @@ class WorkspaceScriptsSearch { private async init(workspace: string) { this.scripts = await ScriptService.listScripts({ - workspace, + workspace }) this.workspace = workspace } @@ -404,30 +272,6 @@ class WorkspaceScriptsSearch { } } -const resourceTypeToolSchema = z.object({ - query: z.string().describe('The query to search for, e.g. stripe, google, etc..'), - language: langSchema.describe( - 'The programming language the code using the resource type will be written in' - ) -}) - -const resourceTypeToolDef = createToolDef( - resourceTypeToolSchema, - 'resource_type', - 'Search for resource types' -) - -const getInstructionsForCodeGenerationToolSchema = z.object({ - id: z.string().describe('The id of the step to generate code for'), - language: langSchema.describe('The programming language the code will be written in') -}) - -const getInstructionsForCodeGenerationToolDef = createToolDef( - getInstructionsForCodeGenerationToolSchema, - 'get_instructions_for_code_generation', - 'Get instructions for code generation for a raw script step' -) - // Will be overridden by setSchema const testRunFlowSchema = z.object({ args: z @@ -458,6 +302,29 @@ const testRunStepToolDef = createToolDef( 'Execute a test run of a specific step in the flow' ) +const inspectInlineScriptSchema = z.object({ + moduleId: z + .string() + .describe('The ID of the module whose inline script content you want to inspect') +}) + +const inspectInlineScriptToolDef = createToolDef( + inspectInlineScriptSchema, + 'inspect_inline_script', + 'Inspect the full content of an inline script. Use this to view the actual script code before making changes with set_module_code.' +) + +const setModuleCodeSchema = z.object({ + moduleId: z.string().describe('The ID of the module to set code for'), + code: z.string().describe('The full script code content') +}) + +const setModuleCodeToolDef = createToolDef( + setModuleCodeSchema, + 'set_module_code', + 'Set or modify the code for an existing inline script module. Use this for quick code-only changes. The module must already exist in the flow.' +) + const workspaceScriptsSearch = new WorkspaceScriptsSearch() export const flowTools: Tool[] = [ @@ -482,229 +349,6 @@ export const flowTools: Tool[] = [ return JSON.stringify(scriptResults) } }, - { - def: addStepToolDef, - fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const parsedArgs = addStepSchema.parse(args) - toolCallbacks.setToolStatus(toolId, { - content: - parsedArgs.location.type === 'after' - ? `Adding a step after step '${parsedArgs.location.afterId}'` - : parsedArgs.location.type === 'start' - ? 'Adding a step at the start' - : parsedArgs.location.type === 'start_inside_forloop' - ? `Adding a step at the start of the forloop step '${parsedArgs.location.inside}'` - : parsedArgs.location.type === 'start_inside_branch' - ? `Adding a step at the start of the branch ${parsedArgs.location.branchIndex + 1} of step '${parsedArgs.location.inside}'` - : parsedArgs.location.type === 'preprocessor' - ? 'Adding a preprocessor step' - : parsedArgs.location.type === 'failure' - ? 'Adding a failure step' - : 'Adding a step' - }) - const id = await helpers.insertStep(parsedArgs.location, parsedArgs.step) - helpers.selectStep(id) - - toolCallbacks.setToolStatus(toolId, { content: `Added step '${id}'` }) - - return `Step ${id} added. Here is the updated flow, make sure to take it into account when adding another step:\n${YAML.stringify(helpers.getModules())}` - } - }, - { - def: removeStepToolDef, - fn: async ({ args, helpers, toolId, toolCallbacks }) => { - toolCallbacks.setToolStatus(toolId, { content: `Removing step ${args.id}...` }) - const parsedArgs = removeStepSchema.parse(args) - helpers.removeStep(parsedArgs.id) - toolCallbacks.setToolStatus(toolId, { content: `Removed step '${parsedArgs.id}'` }) - return `Step '${parsedArgs.id}' removed. Here is the updated flow:\n${YAML.stringify(helpers.getModules())}` - } - }, - { - def: getStepInputsToolDef, - fn: async ({ args, helpers, toolId, toolCallbacks }) => { - toolCallbacks.setToolStatus(toolId, { content: `Getting step ${args.id} inputs...` }) - const parsedArgs = getStepInputsSchema.parse(args) - const inputs = await helpers.getStepInputs(parsedArgs.id) - toolCallbacks.setToolStatus(toolId, { content: `Retrieved step '${parsedArgs.id}' inputs` }) - return YAML.stringify(inputs) - } - }, - { - def: setStepInputsToolDef, - fn: async ({ args, helpers, toolId, toolCallbacks }) => { - toolCallbacks.setToolStatus(toolId, { content: `Setting step ${args.id} inputs...` }) - const parsedArgs = setStepInputsSchema.parse(args) - await helpers.setStepInputs(parsedArgs.id, parsedArgs.inputs) - helpers.selectStep(parsedArgs.id) - const inputs = await helpers.getStepInputs(parsedArgs.id) - toolCallbacks.setToolStatus(toolId, { content: `Set step '${parsedArgs.id}' inputs` }) - return `Step '${parsedArgs.id}' inputs set. New inputs:\n${YAML.stringify(inputs)}` - }, - preAction: ({ toolCallbacks, toolId }) => { - toolCallbacks.setToolStatus(toolId, { content: 'Setting step inputs...' }) - } - }, - { - def: setFlowInputsSchemaToolDef, - fn: async ({ args, helpers, toolId, toolCallbacks }) => { - toolCallbacks.setToolStatus(toolId, { content: 'Setting flow inputs schema...' }) - const parsedArgs = setFlowInputsSchemaSchema.parse(args) - const schema = JSON.parse(parsedArgs.schema) - await helpers.setFlowInputsSchema(schema) - helpers.selectStep('Input') - const updatedSchema = await helpers.getFlowInputsSchema() - toolCallbacks.setToolStatus(toolId, { content: 'Set flow inputs schema' }) - return `Flow inputs schema set. New schema:\n${JSON.stringify(updatedSchema)}` - }, - preAction: ({ toolCallbacks, toolId }) => { - toolCallbacks.setToolStatus(toolId, { content: 'Setting flow inputs schema...' }) - } - }, - { - def: getInstructionsForCodeGenerationToolDef, - fn: async ({ args, toolId, toolCallbacks }) => { - const parsedArgs = getInstructionsForCodeGenerationToolSchema.parse(args) - const langContext = getLangContext(parsedArgs.language, { - allowResourcesFetch: true, - isPreprocessor: parsedArgs.id === 'preprocessor' - }) - toolCallbacks.setToolStatus(toolId, { - content: 'Retrieved instructions for code generation in ' + parsedArgs.language - }) - return langContext - } - }, - { - def: setCodeToolDef, - fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const parsedArgs = setCodeSchema.parse(args) - toolCallbacks.setToolStatus(toolId, { - content: `Setting code for step '${parsedArgs.id}'...` - }) - await helpers.setCode(parsedArgs.id, parsedArgs.code) - helpers.selectStep(parsedArgs.id) - toolCallbacks.setToolStatus(toolId, { content: `Set code for step '${parsedArgs.id}'` }) - return `Step code set` - }, - preAction: ({ toolCallbacks, toolId }) => { - toolCallbacks.setToolStatus(toolId, { content: 'Setting code for step...' }) - } - }, - { - def: setBranchPredicateToolDef, - fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const parsedArgs = setBranchPredicateSchema.parse(args) - await helpers.setBranchPredicate(parsedArgs.id, parsedArgs.branchIndex, parsedArgs.expression) - helpers.selectStep(parsedArgs.id) - toolCallbacks.setToolStatus(toolId, { - content: `Set predicate of branch ${parsedArgs.branchIndex + 1} of '${parsedArgs.id}'` - }) - return `Branch ${parsedArgs.branchIndex} of '${parsedArgs.id}' predicate set` - } - }, - { - def: addBranchToolDef, - fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const parsedArgs = addBranchSchema.parse(args) - await helpers.addBranch(parsedArgs.id) - helpers.selectStep(parsedArgs.id) - toolCallbacks.setToolStatus(toolId, { content: `Added branch to '${parsedArgs.id}'` }) - return `Branch added to '${parsedArgs.id}'` - } - }, - { - def: removeBranchToolDef, - fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const parsedArgs = removeBranchSchema.parse(args) - await helpers.removeBranch(parsedArgs.id, parsedArgs.branchIndex) - helpers.selectStep(parsedArgs.id) - toolCallbacks.setToolStatus(toolId, { - content: `Removed branch ${parsedArgs.branchIndex + 1} of '${parsedArgs.id}'` - }) - return `Branch ${parsedArgs.branchIndex} of '${parsedArgs.id}' removed` - } - }, - { - def: setForLoopIteratorExpressionToolDef, - fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const parsedArgs = setForLoopIteratorExpressionSchema.parse(args) - await helpers.setForLoopIteratorExpression(parsedArgs.id, parsedArgs.expression) - helpers.selectStep(parsedArgs.id) - toolCallbacks.setToolStatus(toolId, { - content: `Set forloop '${parsedArgs.id}' iterator expression` - }) - return `Forloop '${parsedArgs.id}' iterator expression set` - } - }, - { - def: { - ...setForLoopOptionsToolDef, - function: { ...setForLoopOptionsToolDef.function, strict: false } - }, - fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const parsedArgs = setForLoopOptionsSchema.parse(args) - await helpers.setForLoopOptions(parsedArgs.id, { - skip_failures: parsedArgs.skip_failures, - parallel: parsedArgs.parallel, - parallelism: parsedArgs.parallelism - }) - helpers.selectStep(parsedArgs.id) - - const message = `Set forloop '${parsedArgs.id}' options` - toolCallbacks.setToolStatus(toolId, { - content: message - }) - return `${message}: ${JSON.stringify(parsedArgs)}` - } - }, - { - def: { - ...setModuleControlOptionsToolDef, - function: { ...setModuleControlOptionsToolDef.function, strict: false } - }, - fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const parsedArgs = setModuleControlOptionsSchema.parse(args) - await helpers.setModuleControlOptions(parsedArgs.id, { - stop_after_if: parsedArgs.stop_after_if, - stop_after_if_expr: parsedArgs.stop_after_if_expr, - skip_if: parsedArgs.skip_if, - skip_if_expr: parsedArgs.skip_if_expr - }) - helpers.selectStep(parsedArgs.id) - - // Emit UI intent to show early-stop tab when stop_after_if is configured - const modules = helpers.getModules() - const module = findModuleById(modules, parsedArgs.id) - if (!module) { - throw new Error(`Module with id '${parsedArgs.id}' not found in flow.`) - } - const moduleType = module?.value.type - const hasSpecificComponents = ['forloopflow', 'whileloopflow', 'branchall', 'branchone'] - const prefix = hasSpecificComponents.includes(moduleType) ? `${moduleType}` : 'flow' - if (typeof parsedArgs.stop_after_if === 'boolean') { - emitUiIntent({ - kind: 'open_module_tab', - componentId: `${prefix}-${parsedArgs.id}`, - tab: 'early-stop' - }) - } - - if (typeof parsedArgs.skip_if === 'boolean') { - emitUiIntent({ - kind: 'open_module_tab', - componentId: `${prefix}-${parsedArgs.id}`, - tab: 'skip' - }) - } - - const message = `Set module '${parsedArgs.id}' control options` - toolCallbacks.setToolStatus(toolId, { - content: message - }) - return `${message}: ${JSON.stringify(parsedArgs)}` - } - }, { def: resourceTypeToolDef, fn: async ({ args, toolId, workspace, toolCallbacks }) => { @@ -723,6 +367,19 @@ export const flowTools: Tool[] = [ return formattedResourceTypes } }, + { + def: getInstructionsForCodeGenerationToolDef, + fn: async ({ args, toolId, toolCallbacks }) => { + const parsedArgs = getInstructionsForCodeGenerationToolSchema.parse(args) + const langContext = getLangContext(parsedArgs.language, { + allowResourcesFetch: true + }) + toolCallbacks.setToolStatus(toolId, { + content: 'Retrieved instructions for code generation in ' + parsedArgs.language + }) + return langContext + } + }, { def: testRunFlowToolDef, fn: async function ({ args, workspace, helpers, toolCallbacks, toolId }) { @@ -739,15 +396,15 @@ export const flowTools: Tool[] = [ } const parsedArgs = await buildTestRunArgs(args, this.def) + // Use the UI test mechanism - this opens the preview panel return executeTestRun({ - jobStarter: () => - JobService.runFlowPreview({ - workspace: workspace, - requestBody: { - args: parsedArgs, - value: flow.value - } - }), + jobStarter: async () => { + const jobId = await helpers.testFlow(parsedArgs) + if (!jobId) { + throw new Error('Failed to start test run - testFlow returned undefined') + } + return jobId + }, workspace, toolCallbacks, toolId, @@ -812,7 +469,7 @@ export const flowTools: Tool[] = [ content: moduleValue.content ?? '', language: moduleValue.language, args: - module.id === 'preprocessor' + module.id === SPECIAL_MODULE_IDS.PREPROCESSOR ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...stepArgs } : stepArgs } @@ -843,7 +500,7 @@ export const flowTools: Tool[] = [ content: script.content, language: script.language, args: - module.id === 'preprocessor' + module.id === SPECIAL_MODULE_IDS.PREPROCESSOR ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...stepArgs } : stepArgs } @@ -882,134 +539,678 @@ export const flowTools: Tool[] = [ requiresConfirmation: true, confirmationMessage: 'Run flow step test', showDetails: true + }, + { + def: inspectInlineScriptToolDef, + fn: async ({ args, toolCallbacks, toolId }) => { + const parsedArgs = inspectInlineScriptSchema.parse(args) + const moduleId = parsedArgs.moduleId + + toolCallbacks.setToolStatus(toolId, { + content: `Retrieving inline script content for module '${moduleId}'...` + }) + + const content = inlineScriptStore.get(moduleId) + + if (content === undefined) { + toolCallbacks.setToolStatus(toolId, { + content: `Module '${moduleId}' not found in inline script store`, + error: `No inline script found for module ID '${moduleId}'` + }) + throw new Error( + `Module '${moduleId}' not found. This module either doesn't exist, isn't a rawscript, or wasn't replaced with a reference.` + ) + } + + toolCallbacks.setToolStatus(toolId, { + content: `Retrieved inline script for module '${moduleId}'` + }) + + return JSON.stringify({ + moduleId, + content, + note: 'To modify this script, use the set_module_code tool with the new code' + }) + } + }, + { + def: setModuleCodeToolDef, + streamArguments: true, + showDetails: true, + showFade: true, + fn: async ({ args, helpers, toolId, toolCallbacks }) => { + const parsedArgs = setModuleCodeSchema.parse(args) + const { moduleId, code } = parsedArgs + + toolCallbacks.setToolStatus(toolId, { content: `Setting code for module '${moduleId}'...` }) + + // Update store to keep it coherent (for subsequent set_flow_json calls with references) + inlineScriptStore.set(moduleId, code) + + // Update the flow directly via helper + await helpers.setCode(moduleId, code) + + toolCallbacks.setToolStatus(toolId, { + content: `Code updated for module '${moduleId}'`, + result: 'Success' + }) + return `Code for module '${moduleId}' has been updated successfully.` + } + }, + { + def: { ...addModuleToolDef, function: { ...addModuleToolDef.function, strict: false } }, + streamArguments: true, + showDetails: true, + showFade: true, + fn: async ({ args, helpers, toolId, toolCallbacks }) => { + const afterId = (args.afterId ?? null) as string | null + const insideId = (args.insideId ?? null) as string | null + const branchPath = (args.branchPath ?? null) as string | null + let value = args.value + + // Parse value if it's a JSON string + if (typeof value === 'string') { + try { + value = JSON.parse(value) + } catch (e) { + throw new Error(`Failed to parse value as JSON: ${(e as Error).message}`) + } + } + + // Validation + // branchPath can be null when adding a new branch to branchall/branchone + // In that case, value should be a branch object with summary, modules, etc. + const isAddingNewBranch = insideId && branchPath === null + + if (!isAddingNewBranch) { + // Adding a regular module - requires id + if (!value.id) { + throw new Error('Module value must include an id field') + } + // Check for restricted IDs + if (isRestrictedModuleId(value.id)) { + throw new Error(`Restricted id '${value.id}', can't be used, should choose an other`) + } + } + + const statusMessage = isAddingNewBranch + ? `Adding new branch to '${insideId}'...` + : `Adding module '${value.id}'...` + toolCallbacks.setToolStatus(toolId, { content: statusMessage }) + + const { flow } = helpers.getFlowAndSelectedId() + + let processedValue = value + + // When adding a branch (not a module), skip ID checks and inline script handling + if (!isAddingNewBranch) { + // Check for duplicate IDs (including nested modules) + const allNewIds = collectAllModuleIds(processedValue as FlowModule) + for (const newId of allNewIds) { + const existing = findModuleInFlow(flow.value.modules, newId) + if (existing) { + throw new Error( + `Module with id '${newId}' already exists in the flow. Each module ID must be unique.` + ) + } + } + + // Handle inline script storage if this is a rawscript with full content + if ( + processedValue.value?.type === 'rawscript' && + processedValue.value?.content && + !processedValue.value.content.startsWith('inline_script.') + ) { + // Store the content and replace with reference + inlineScriptStore.set(processedValue.id, processedValue.value.content) + processedValue = { + ...processedValue, + value: { + ...processedValue.value, + content: `inline_script.${processedValue.id}` + } + } + } + } + + // Add the module + const updatedModules = addModuleToFlow( + flow.value.modules, + afterId, + insideId, + branchPath, + processedValue as FlowModule + ) + + // Apply via setFlowJson to trigger proper snapshot and diff tracking + const updatedFlow = { + ...flow.value, + modules: updatedModules + } + + await helpers.setFlowJson(JSON.stringify(updatedFlow)) + + // Update exprsToSet if this module is selected and has input_transforms + if (value.id && value.value?.input_transforms) { + helpers.updateExprsToSet(value.id, value.value.input_transforms) + } + + toolCallbacks.setToolStatus(toolId, { + content: `Module '${value.id}' added successfully`, + result: 'Success' + }) + return `Module '${value.id}' has been added to the flow.` + } + }, + { + def: { ...removeModuleToolDef, function: { ...removeModuleToolDef.function, strict: false } }, + fn: async ({ args, helpers, toolId, toolCallbacks }) => { + const parsedArgs = removeModuleSchema.parse(args) + const { id } = parsedArgs + + // Check for restricted IDs + if (isRestrictedModuleId(id)) { + throw new Error(`Restricted id '${id}', can't be used, should choose an other`) + } + + toolCallbacks.setToolStatus(toolId, { content: `Removing module '${id}'...` }) + + const { flow } = helpers.getFlowAndSelectedId() + + // Check module exists + const existing = findModuleInFlow(flow.value.modules, id) + if (!existing) { + throw new Error(`Module with id '${id}' not found`) + } + + // Remove the module + const updatedModules = removeModuleFromFlow(flow.value.modules, id) + + // Apply via setFlowJson to trigger proper snapshot and diff tracking + const updatedFlow = { + ...flow.value, + modules: updatedModules + } + + await helpers.setFlowJson(JSON.stringify(updatedFlow)) + + toolCallbacks.setToolStatus(toolId, { content: `Module '${id}' removed successfully` }) + return `Module '${id}' has been removed from the flow.` + } + }, + { + def: removeBranchToolDef, + fn: async ({ args, helpers, toolId, toolCallbacks }) => { + const parsedArgs = removeBranchSchema.parse(args) + const { insideId, branchIndex } = parsedArgs + + toolCallbacks.setToolStatus(toolId, { + content: `Removing branch ${branchIndex} from '${insideId}'...` + }) + + const { flow } = helpers.getFlowAndSelectedId() + + // Check container exists + const container = findModuleInFlow(flow.value.modules, insideId) + if (!container) { + throw new Error(`Container module with id '${insideId}' not found`) + } + + // Validate it's a branchall/branchone + if (container.value.type !== 'branchall' && container.value.type !== 'branchone') { + throw new Error( + `Module '${insideId}' is not a branchall/branchone (type: ${container.value.type})` + ) + } + + // Remove the branch + const updatedModules = removeBranchFromFlow(flow.value.modules, insideId, branchIndex) + + // Apply via setFlowJson + const updatedFlow = { + ...flow.value, + modules: updatedModules + } + + await helpers.setFlowJson(JSON.stringify(updatedFlow)) + + toolCallbacks.setToolStatus(toolId, { + content: `Branch ${branchIndex} removed from '${insideId}'` + }) + return `Branch ${branchIndex} has been removed from '${insideId}'.` + } + }, + { + def: { ...modifyModuleToolDef, function: { ...modifyModuleToolDef.function, strict: false } }, + streamArguments: true, + showDetails: true, + showFade: true, + fn: async ({ args, helpers, toolId, toolCallbacks }) => { + let { id, value } = args + + // Check for restricted IDs + if (isRestrictedModuleId(id)) { + throw new Error(`Restricted id '${id}', can't be used, should choose an other`) + } + + // Parse value if it's a JSON string + if (typeof value === 'string') { + try { + value = JSON.parse(value) + } catch (e) { + throw new Error(`Failed to parse value as JSON: ${(e as Error).message}`) + } + } + + toolCallbacks.setToolStatus(toolId, { content: `Modifying module '${id}'...` }) + + const { flow } = helpers.getFlowAndSelectedId() + + // Check module exists + const existing = findModuleInFlow(flow.value.modules, id) + if (!existing) { + throw new Error(`Module with id '${id}' not found`) + } + + // Handle inline script storage if this is a rawscript with full content + let processedValue = value + if ( + processedValue.value?.type === 'rawscript' && + processedValue.value?.content && + !processedValue.value.content.startsWith('inline_script.') + ) { + // Store the content and replace with reference + inlineScriptStore.set(id, processedValue.value.content) + processedValue = { + ...processedValue, + value: { + ...processedValue.value, + content: `inline_script.${id}` + } + } + } + + // Replace the module + const updatedModules = replaceModuleInFlow( + flow.value.modules, + id, + processedValue as FlowModule + ) + + // Apply via setFlowJson to trigger proper snapshot and diff tracking + const updatedFlow = { + ...flow.value, + modules: updatedModules + } + + await helpers.setFlowJson(JSON.stringify(updatedFlow)) + + // Update exprsToSet if this module is selected and has input_transforms + if (value.value?.input_transforms) { + helpers.updateExprsToSet(id, value.value.input_transforms) + } + + toolCallbacks.setToolStatus(toolId, { + content: `Module '${id}' modified successfully`, + result: 'Success' + }) + return `Module '${id}' has been modified.` + } + }, + { + def: { ...setFlowSchemaToolDef, function: { ...setFlowSchemaToolDef.function, strict: false } }, + fn: async ({ args, helpers, toolId, toolCallbacks }) => { + let { schema } = args + + // If schema is a JSON string, parse it to an object + if (typeof schema === 'string') { + try { + schema = JSON.parse(schema) + } catch (e) { + // If it fails to parse, keep it as-is and let it fail downstream + console.warn('SCHEMA failed to parse as JSON string', e) + } + } + + toolCallbacks.setToolStatus(toolId, { content: 'Setting flow input schema...' }) + + const { flow } = helpers.getFlowAndSelectedId() + + // Update the flow with new schema + const updatedFlow = { + ...flow.value, + schema + } + + await helpers.setFlowJson(JSON.stringify(updatedFlow)) + + toolCallbacks.setToolStatus(toolId, { content: 'Flow input schema updated successfully' }) + return 'Flow input schema has been updated.' + } } ] +/** + * Formats the OpenFlow schema for inclusion in the AI system prompt. + * Extracts only the component schemas and formats them as JSON for the AI to reference. + */ +function formatOpenFlowSchemaForPrompt(): string { + const schemas = openFlowSchema.components?.schemas + if (!schemas) { + return 'Schema not available' + } + + // Create a simplified schema reference that's easier for the AI to parse + return JSON.stringify(schemas, null, 2) +} + export function prepareFlowSystemMessage(customPrompt?: string): ChatCompletionSystemMessageParam { - let content = `You are a helpful assistant that creates and edits workflows on the Windmill platform. You're provided with a bunch of tools to help you edit the flow. + let content = `You are a helpful assistant that creates and edits workflows on the Windmill platform. + +## IMPORTANT RULES + +**Reserved IDs - Do NOT use these in add_module, modify_module, or remove_module:** +- \`failure\` - Reserved for failure handler module +- \`preprocessor\` - Reserved for preprocessor module +- \`Input\` - Reserved for flow input reference + +## Tool Selection Guide + +**Flow Modification:** +- **Add a new module** → \`add_module\` +- **Remove a module** → \`remove_module\` +- **Add a new branch to branchall/branchone** → \`add_module\` with \`branchPath: null\` +- **Remove a branch from branchall/branchone** → \`remove_branch\` +- **Change module code only** → \`set_module_code\` +- **Change module config/transforms/conditions** → \`modify_module\` +- **Update flow input parameters** → \`set_flow_schema\` + +**Code & Scripts:** +- **View existing inline script code** → \`inspect_inline_script\` +- **Get language-specific coding instructions** → \`get_instructions_for_code_generation\` (call BEFORE writing code) +- **Find workspace scripts** → \`search_scripts\` +- **Find Windmill Hub scripts** → \`search_hub_scripts\` + +**Testing:** +- **Test entire flow** → \`test_run_flow\` +- **Test single step** → \`test_run_step\` + +**Resources & Schema:** +- **Search resource types** → \`resource_type\` +- **Get database schema** → \`get_db_schema\` + +## Common Mistakes to Avoid + +- **Don't use \`modify_module\` to add/remove nested modules** - Use \`add_module\`/\`remove_module\` instead +- **Don't forget \`input_transforms\`** - Rawscript parameters won't receive values without them +- **Don't use spaces in module IDs** - Use underscores (e.g., \`fetch_data\` not \`fetch data\`) +- **Don't reference future steps** - \`results.step_id\` only works for steps that execute before the current one +- **Don't create duplicate IDs** - Each module ID must be unique in the flow. Always generate fresh, unique IDs for new modules. Never reuse IDs from existing or previously removed modules + +## Flow Modification Tools + +### add_module +Add a new module to the flow, or add a new branch to a branchall/branchone. + +**Parameters:** +- \`afterId\`: ID of module to insert after, or \`null\` to insert at beginning +- \`insideId\` + \`branchPath\`: For inserting into containers (branches/loops/AI agents) +- \`insideId\` + \`branchPath: null\`: For adding a NEW branch to branchall/branchone +- \`value\`: The module object (or branch object when adding a new branch) + +**Valid \`branchPath\` values:** +- \`"modules"\` - for forloopflow/whileloopflow +- \`"branches.0"\`, \`"branches.1"\`, etc. - to add inside a specific branch +- \`"default"\` - for branchone only +- \`"tools"\` - for aiagent +- \`null\` - to add a NEW branch to branchall/branchone + +**Examples:** +\`\`\`javascript +// Insert after step_a +add_module({ afterId: "step_a", value: {...} }) + +// Insert at beginning of flow +add_module({ afterId: null, value: {...} }) + +// Insert into first branch, at beginning +add_module({ insideId: "branch_step", branchPath: "branches.0", afterId: null, value: {...} }) + +// Insert into first branch, after step_x +add_module({ insideId: "branch_step", branchPath: "branches.0", afterId: "step_x", value: {...} }) + +// Insert into loop +add_module({ insideId: "loop_step", branchPath: "modules", afterId: null, value: {...} }) + +// Add a NEW branch to branchall (branchPath: null) +add_module({ insideId: "my_branchall", branchPath: null, value: { summary: "New Branch", skip_failure: false, modules: [] } }) + +// Add a NEW branch to branchone (branchPath: null) +add_module({ insideId: "my_branchone", branchPath: null, value: { summary: "New Condition", expr: "results.step_a > 10", modules: [] } }) +\`\`\` + +### remove_module +Remove a module by ID. +\`\`\`javascript +remove_module({ id: "step_b" }) +\`\`\` + +### remove_branch +Remove a branch from a branchall/branchone by its index (0-based). +\`\`\`javascript +// Remove the first branch (index 0) from a branchall +remove_branch({ insideId: "my_branchall", branchIndex: 0 }) + +// Remove the second branch (index 1) from a branchone +remove_branch({ insideId: "my_branchone", branchIndex: 1 }) +\`\`\` +**Note:** This removes the entire branch including all modules inside it. + +### modify_module +Update an existing module (full replacement). Use for changing configuration, input_transforms, branch conditions, etc. +Do NOT use for adding/removing nested modules - use add_module/remove_module instead. +\`\`\`javascript +modify_module({ id: "step_a", value: {...} }) +\`\`\` + +### set_module_code +Modify only the code of an existing inline script module. Use for quick code-only changes. +\`\`\`javascript +set_module_code({ moduleId: "step_a", code: "..." }) +\`\`\` + +### set_flow_schema +Set/update flow input parameters. +\`\`\`javascript +set_flow_schema({ schema: { type: "object", properties: { user_id: { type: "string" } }, required: ["user_id"] } }) +\`\`\` + Follow the user instructions carefully. -Go step by step, and explain what you're doing as you're doing it. -DO NOT wait for user confirmation before performing an action. Only do it if the user explicitly asks you to wait in their initial instructions. +At the end of your changes, explain precisely what you did and what the flow does now. ALWAYS test your modifications. You have access to the \`test_run_flow\` and \`test_run_step\` tools to test the flow and steps. If you only modified a single step, use the \`test_run_step\` tool to test it. If you modified the flow, use the \`test_run_flow\` tool to test it. If the user cancels the test run, do not try again and wait for the next user instruction. When testing steps that are sql scripts, the arguments to be passed are { database: $res: }. -## Code Markers in Flow Modules +### Inline Script References (Token Optimization) -When viewing flow modules, the code content of rawscript steps may include \`[#START]\` and \`[#END]\` markers: -- These markers indicate specific code sections that need attention -- You MUST only modify the code between these markers when using the \`set_code\` tool -- After modifying the code, remove the markers from your response -- If a question is asked about the code, focus only on the code between the markers -- The markers appear in the YAML representation of flow modules when specific code pieces are selected +To reduce token usage, rawscript content in the flow you receive is replaced with references in the format \`inline_script.{module_id}\`. For example: -## Understanding User Requests +\`\`\`json +{ + "id": "step_a", + "value": { + "type": "rawscript", + "content": "inline_script.step_a", + "language": "bun" + } +} +\`\`\` -### Individual Actions -When the user asks for a specific action, perform ONLY that action: -- Updating code for a step -- Setting step inputs -- Setting flow inputs schema -- Setting branch predicates -- Setting forloop iterator expressions -- Adding/removing branches -- etc. +**To modify existing script code:** +- Use \`set_module_code\` tool for code-only changes: \`set_module_code({ moduleId: "step_a", code: "..." })\` -### Full Step Creation Process -When the user asks to add one or more steps with broad instructions (e.g., "add a step to send an email", "create a flow to process data"), follow the complete process below for EACH step. +**To add a new inline script module:** +- Use \`add_module\` with the full code content directly (not a reference) +- Avoid coding in single lines, always use multi-line code blocks. +- The system will automatically store and optimize it -### Complete Step Creation Process -When creating new steps, follow this process for EACH step: -1. If the user hasn't explicitly asked to write from scratch: - - First search for matching scripts in the workspace - - Then search for matching scripts in the hub, but ONLY consider highly relevant results that match the user's requirements - - Only if no suitable script is found, create a raw script step -2. For raw script steps: - - If no language is specified, use 'bun' as the default language - - Use get_instructions_for_code_generation to get the correct code format - - Display the code to the user before setting it - - Set the code using set_code -3. After adding any step: - - Get the step inputs using get_step_inputs - - Set the step inputs using set_step_inputs - - If any inputs use flow_input properties that don't exist yet, add them to the schema using set_flow_inputs_schema +**To inspect existing code:** +- Use \`inspect_inline_script\` tool to view the current code: \`inspect_inline_script({ moduleId: "step_a" })\` -## Additional instructions for the Flow Editor +### Input Transforms for Rawscripts -### Special Step Types -For special step types, follow these additional steps: -- For forloop steps: - - Set the iterator expression using set_forloop_iterator_expression - - Set advanced options (parallel, parallelism, skip_failures) using set_forloop_options -- For branchone steps: Set the predicates for each branch using set_branch_predicate -- For branchall steps: No additional setup needed +Rawscript modules use \`input_transforms\` to map function parameters to values. Each key in \`input_transforms\` corresponds to a parameter name in your script's \`main\` function. -### Module Control Options -For any module type, you can set control flow options using set_module_control_options: -- **stop_after_if**: Early stop condition - stops the module if expression evaluates to true. Can use "flow_input" or "result". "result" is the result of the step. "results." is not supported, do not use it. Example: "flow_input.x > 10" or "result === "failure"" -- **skip_if**: Skip condition - skips the module entirely if expression evaluates to true. Can use "flow_input" or "results.". Example: "flow_input.x > 10" or "results.a === "failure"" +**Transform Types:** +- \`static\`: Fixed value passed directly +- \`javascript\`: Dynamic expression evaluated at runtime -### Step Insertion Rules -When adding steps, carefully consider the execution order: -1. Steps are executed in the order they appear in the flow definition, not in the order they were added -2. For inserting steps: - - Use 'start' to add at the beginning of the flow - - Use 'after' with the previous step's ID to add in sequence (can be inside a branch or a forloop) - - Use 'start_inside_forloop' to add at the start of a forloop - - Use 'start_inside_branch' to add at the start of a branch - - Use 'preprocessor' to add a preprocessor step - - Use 'failure' to add a failure step -3. Always verify the flow structure after adding steps to ensure correct execution order +**Available Variables in JavaScript Expressions:** +- \`flow_input.{property}\` - Access flow input parameters +- \`results.{step_id}\` - Access output from a previous step +- \`flow_input.iter.value\` - Current item when inside a for-loop +- \`flow_input.iter.index\` - Current index when inside a for-loop -### Flow Inputs and Schema -- Use set_flow_inputs_schema to define or update the flow's input schema -- When using flow_input in step inputs, ensure the properties exist in the schema -- For resource inputs, set the property type to "object" and add a "format" key with value "resource-nameofresourcetype" +**Example - Rawscript using flow input and previous step result:** +\`\`\`json +{ + "id": "step_b", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(userId: string, data: any[]) { + return "Hello, world!"; + }", + "input_transforms": { + "userId": { + "type": "javascript", + "expr": "flow_input.user_id" + }, + "data": { + "type": "javascript", + "expr": "results.step_a" + } + } + } +} +\`\`\` -### JavaScript Expressions -For step inputs, forloop iterator expressions and branch predicates, use JavaScript expressions with these variables: -- Step results: results.stepid or results.stepid.property_name -- Break condition (stop_after_if) in for loops: result (contains the result of the last iteration) -- Loop iterator: flow_input.iter.value (inside loops) -- Flow inputs: flow_input.property_name -- Static values: Use JavaScript syntax (e.g., "hello", true, 3) +**Example - Static value:** +\`\`\`json +{ + "input_transforms": { + "limit": { + "type": "static", + "value": 100 + } + } +} +\`\`\` -Note: These variables are only accessible in step inputs, forloop iterator expressions and branch predicates. They must be passed as script arguments using the set_step_inputs tool. +**Important:** The parameter names in \`input_transforms\` must match the function parameter names in your script. When you create or modify a rawscript, always define \`input_transforms\` to connect it to flow inputs or results from other steps. -For truly static values in step inputs (those not linked to previous steps or loop iterations), prefer using flow inputs by default unless explicitly specified otherwise. This makes the flow more configurable and reusable. For example, instead of hardcoding an email address in a step input, create a flow input for it. +### Other Key Concepts +- **Resources**: For flow inputs, use type "object" with format "resource-". For step inputs, use "$res:path/to/resource" +- **Module IDs**: Must be unique and valid identifiers. Used to reference results via \`results.step_id\` +- **Module types**: Use 'bun' as default language for rawscript if unspecified -### For Loop Advanced Options -When configuring for-loop steps, consider these options: -- **parallel: true** - Run iterations in parallel for independent operations (significantly faster for I/O bound tasks) -- **parallelism: N** - Limit concurrent iterations (only applies when parallel=true). Use to prevent overwhelming external APIs -- **skip_failures: true** - Continue processing remaining iterations even if some fail. Failed iterations return error objects as results +### Writing Code for Modules -### Special Modules -- Preprocessor: Runs before the first step when triggered externally - - ID: 'preprocessor' - - Cannot link inputs - - Only supports script/rawscript steps -- Error handler: Runs when the flow fails - - ID: 'failure' - - Can only reference flow_input and error object - - Error object structure: { message, name, stack, step_id } - - Only supports script/rawscript steps +**IMPORTANT: Before writing any code for a rawscript module, you MUST call the \`get_instructions_for_code_generation\` tool with the target language.** This tool provides essential language-specific instructions. -Both modules only support a script or rawscript step. You cannot nest modules using forloop/branchone/branchall. +Always call this tool first when: +- Creating a new rawscript module +- Modifying existing code in a module +- Setting code via \`set_module_code\` + +Example: Before writing TypeScript/Bun code, call \`get_instructions_for_code_generation({ language: "bun" })\` + +### Creating New Steps + +1. **Search for existing scripts first** (unless user explicitly asks to write from scratch): + - First: \`search_scripts\` to find workspace scripts + - Then: \`search_hub_scripts\` (only consider highly relevant results) + - Only create a raw script if no suitable script is found + +2. **Add the module using \`add_module\`:** + - If using existing script: \`add_module({ afterId: "previous_step", value: { id: "new_step", value: { type: "script", path: "f/folder/script" } } })\` + - If creating rawscript: + - Default language is 'bun' if not specified + - **First call \`get_instructions_for_code_generation\` to get the correct code format** + - Include full code in the content field + - Always define \`input_transforms\` to connect parameters to flow inputs or previous step results + +3. **Update flow schema if needed:** + - If your module references flow_input properties that don't exist yet, add them using \`set_flow_schema\` + +### AI Agent Tools + +AI agents can use tools to accomplish tasks. To manage tools for an AI agent: + +- **Adding a tool to an AI agent**: Use \`add_module\` with \`insideId\` set to the agent's ID and \`branchPath: "tools"\` + - Tool order doesn't affect execution, so you can omit \`afterId\` (defaults to inserting at beginning) + - Example: \`add_module({ insideId: "ai_agent_step", branchPath: "tools", value: { id: "search_docs", summary: "Search documentation", value: { tool_type: "flowmodule", type: "rawscript", language: "bun", content: "...", input_transforms: {} } } })\` + +- **Removing a tool from an AI agent**: Use \`remove_module\` with the tool's ID + - The tool will be found and removed from the agent's tools array + +- **Modifying a tool**: Use \`modify_module\` with the tool's ID + - Example: \`modify_module({ id: "search_docs", value: { ... } })\` + +- **Tool IDs**: Cannot contain spaces - use underscores (e.g., \`get_user_data\` not \`get user data\`) +- **Tool summaries**: Unlike other module summaries, tool summaries cannot contain spaces, use underscores instead. + +- **Tool types**: + - \`flowmodule\`: A script/flow that the agent can call (same as regular flow modules but with \`tool_type: "flowmodule"\`) + - \`mcp\`: Reference to an MCP server tool + +**Example - Adding a rawscript tool to an agent:** +\`\`\`json +add_module({ + insideId: "my_agent", + branchPath: "tools", + value: { + id: "fetch_weather", + summary: "Get current weather for a location", + value: { + tool_type: "flowmodule", + type: "rawscript", + language: "bun", + content: "export async function main(location: string) { ... }", + input_transforms: { + location: { type: "static", value: "" } + } + } + } +}) +\`\`\` + +## Resource Types +On Windmill, credentials and configuration are stored in resources. Resource types define the format of the resource. +- Use the \`resource_type\` tool to search for available resource types (e.g. stripe, google, postgresql, etc.) +- If the user needs a resource as flow input, set the property type in the schema to "object" and add a key called "format" set to "resource-nameofresourcetype" (e.g. "resource-stripe") +- If the user wants a specific resource as step input, set the step value to a static string in the format: "$res:path/to/resource" + +### OpenFlow Schema Reference +Below is the complete OpenAPI schema for OpenFlow. All field descriptions and behaviors are defined here. Refer to this as the authoritative reference when generating flow JSON: + +\`\`\`json +${formatOpenFlowSchemaForPrompt()} +\`\`\` + +The schema includes detailed descriptions for: +- **FlowModuleValue types**: rawscript, script, flow, forloopflow, whileloopflow, branchone, branchall, identity, aiagent +- **Module configuration**: stop_after_if, skip_if, suspend, sleep, cache_ttl, retry, mock, timeout +- **InputTransform**: static vs javascript, available variables (results, flow_input, flow_input.iter) +- **Special modules**: preprocessor_module, failure_module +- **Loop options**: iterator, parallel, parallelism, skip_failures +- **Branch types**: BranchOne (first match), BranchAll (all execute) ### Contexts You have access to the following contexts: -- Database schemas -- Flow diffs -- Focused flow modules -Database schemas give you the schema of databases the user is using. -Flow diffs give you the diff between the current flow and the last deployed flow. -Focused flow modules give you the ids of the flow modules the user is focused on. Your response should focus on these modules. - -## Resource types -On Windmill, credentials and configuration are stored in resources. Resource types define the format of the resource. -If the user needs a resource as flow input, you should set the property type in the schema to "object" as well as add a key called "format" and set it to "resource-nameofresourcetype" (e.g. "resource-stripe"). -If the user wants a specific resource as step input, you should set the step value to a static string in the following format: "$res:path/to/resource". +- Database schemas: Schema of databases the user is using +- Flow diffs: Diff between current flow and last deployed flow +- Focused flow modules: IDs of modules the user is focused on. Your response should focus on these modules ` // If there's a custom prompt, append it to the system prompt @@ -1044,20 +1245,48 @@ ${instructions}` } const codePieces = selectedContext.filter((c) => c.type === 'flow_module_code_piece') - const flowModulesYaml = applyCodePiecesToFlowModules(codePieces, flow.value.modules) - let flowContent = `## FLOW: -flow_input schema: -${JSON.stringify(flow.schema ?? emptySchema())} + // Clear the inline script store and extract inline scripts for token optimization + inlineScriptStore.clear() + const optimizedModules = extractAndReplaceInlineScripts(flow.value.modules) -flow modules: -${flowModulesYaml} + // Apply code pieces to the optimized modules (returns YAML string) + const flowModulesYaml = applyCodePiecesToFlowModules(codePieces, optimizedModules) -preprocessor module: -${YAML.stringify(flow.value.preprocessor_module)} + // Handle preprocessor and failure modules + let optimizedPreprocessor = flow.value.preprocessor_module + if (optimizedPreprocessor?.value?.type === 'rawscript' && optimizedPreprocessor.value.content) { + inlineScriptStore.set(optimizedPreprocessor.id, optimizedPreprocessor.value.content) + optimizedPreprocessor = { + ...optimizedPreprocessor, + value: { + ...optimizedPreprocessor.value, + content: `inline_script.${optimizedPreprocessor.id}` + } + } + } -failure module: -${YAML.stringify(flow.value.failure_module)} + let optimizedFailure = flow.value.failure_module + if (optimizedFailure?.value?.type === 'rawscript' && optimizedFailure.value.content) { + inlineScriptStore.set(optimizedFailure.id, optimizedFailure.value.content) + optimizedFailure = { + ...optimizedFailure, + value: { + ...optimizedFailure.value, + content: `inline_script.${optimizedFailure.id}` + } + } + } + + const finalFlow = { + schema: flow.schema, + modules: flowModulesYaml, + preprocessor_module: optimizedPreprocessor, + failure_module: optimizedFailure + } + + let flowContent = `## CURRENT FLOW JSON: +${JSON.stringify(finalFlow, null, 2)} currently selected step: ${selectedId}` diff --git a/frontend/src/lib/components/copilot/chat/flow/inlineScriptsUtils.ts b/frontend/src/lib/components/copilot/chat/flow/inlineScriptsUtils.ts new file mode 100644 index 0000000000..9a124c44d7 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/flow/inlineScriptsUtils.ts @@ -0,0 +1,284 @@ +import type { FlowModule } from '$lib/gen' + +/** + * Storage for inline scripts extracted from flow modules. + * Maps module IDs to their rawscript content for token-efficient transmission to AI. + */ +class InlineScriptStore { + private scripts: Map = new Map() + + clear() { + this.scripts.clear() + } + + set(moduleId: string, content: string) { + this.scripts.set(moduleId, content) + } + + get(moduleId: string): string | undefined { + return this.scripts.get(moduleId) + } + + has(moduleId: string): boolean { + return this.scripts.has(moduleId) + } + + getAll(): Record { + return Object.fromEntries(this.scripts.entries()) + } +} + +export const inlineScriptStore = new InlineScriptStore() + +/** + * Recursively extracts all rawscript content from flow modules and stores them. + * Replaces the content with references like "inline_script.{module_id}". + */ +export function extractAndReplaceInlineScripts(modules: FlowModule[]): FlowModule[] { + if (!modules || !Array.isArray(modules)) { + return [] + } + + return modules.map((module) => { + const newModule = { ...module } + + if (newModule.value.type === 'rawscript' && newModule.value.content) { + // Store the original content + inlineScriptStore.set(module.id, newModule.value.content) + + // Replace with reference + newModule.value = { + ...newModule.value, + content: `inline_script.${module.id}` + } + } else if (newModule.value.type === 'forloopflow' || newModule.value.type === 'whileloopflow') { + // Recursively process nested modules in loops + if (newModule.value.modules) { + newModule.value = { + ...newModule.value, + modules: extractAndReplaceInlineScripts(newModule.value.modules) + } + } + } else if (newModule.value.type === 'branchone') { + // Process branches and default modules + if (newModule.value.branches) { + newModule.value = { + ...newModule.value, + branches: newModule.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules ? extractAndReplaceInlineScripts(branch.modules) : [] + })) + } + } + if (newModule.value.default) { + newModule.value = { + ...newModule.value, + default: extractAndReplaceInlineScripts(newModule.value.default) + } + } + } else if (newModule.value.type === 'branchall') { + // Process all branches + if (newModule.value.branches) { + newModule.value = { + ...newModule.value, + branches: newModule.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules ? extractAndReplaceInlineScripts(branch.modules) : [] + })) + } + } + } else if (newModule.value.type === 'aiagent') { + // Process AI agent tools + if (newModule.value.tools) { + newModule.value = { + ...newModule.value, + tools: newModule.value.tools.map((tool) => { + if ( + tool.value && + 'tool_type' in tool.value && + tool.value.tool_type === 'flowmodule' && + 'type' in tool.value && + tool.value.type === 'rawscript' && + 'content' in tool.value && + tool.value.content + ) { + inlineScriptStore.set(tool.id, tool.value.content as string) + return { + ...tool, + value: { + ...tool.value, + content: `inline_script.${tool.id}` + } + } + } + return tool + }) + } + } + } + + return newModule + }) +} + +/** + * Recursively restores inline script references back to their full content. + * If content matches pattern "inline_script.{id}", looks up and restores the original. + * If content doesn't match (new/modified script), keeps it as-is. + */ +export function restoreInlineScriptReferences(modules: FlowModule[]): FlowModule[] { + return modules.map((module) => { + const newModule = { ...module } + + if (newModule.value.type === 'rawscript' && newModule.value.content) { + const content = newModule.value.content + // Check if it's a reference + const match = content.match(/^inline_script\.(.+)$/) + if (match) { + const moduleId = match[1] + const storedContent = inlineScriptStore.get(moduleId) + if (storedContent !== undefined) { + // Restore original content + newModule.value = { + ...newModule.value, + content: storedContent + } + } + // If not found in store, keep the reference as-is (shouldn't happen normally) + } + // If not a reference, it's new/modified content - keep as-is + } else if (newModule.value.type === 'forloopflow' || newModule.value.type === 'whileloopflow') { + // Recursively process nested modules in loops + if (newModule.value.modules) { + newModule.value = { + ...newModule.value, + modules: restoreInlineScriptReferences(newModule.value.modules) + } + } + } else if (newModule.value.type === 'branchone') { + // Process branches and default modules + if (newModule.value.branches) { + newModule.value = { + ...newModule.value, + branches: newModule.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules ? restoreInlineScriptReferences(branch.modules) : [] + })) + } + } + if (newModule.value.default) { + newModule.value = { + ...newModule.value, + default: restoreInlineScriptReferences(newModule.value.default) + } + } + } else if (newModule.value.type === 'branchall') { + // Process all branches + if (newModule.value.branches) { + newModule.value = { + ...newModule.value, + branches: newModule.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules ? restoreInlineScriptReferences(branch.modules) : [] + })) + } + } + } else if (newModule.value.type === 'aiagent') { + // Process AI agent tools + if (newModule.value.tools) { + newModule.value = { + ...newModule.value, + tools: newModule.value.tools.map((tool) => { + if ( + tool.value && + 'tool_type' in tool.value && + tool.value.tool_type === 'flowmodule' && + 'type' in tool.value && + tool.value.type === 'rawscript' && + 'content' in tool.value && + tool.value.content + ) { + const content = tool.value.content as string + const match = content.match(/^inline_script\.(.+)$/) + if (match) { + const toolId = match[1] + const storedContent = inlineScriptStore.get(toolId) + if (storedContent !== undefined) { + return { + ...tool, + value: { + ...tool.value, + content: storedContent + } + } + } + } + } + return tool + }) + } + } + } + + return newModule + }) +} + +/** + * Recursively finds any unresolved inline script references in flow modules. + * Returns array of module IDs that still have `inline_script.{id}` patterns. + */ +export function findUnresolvedInlineScriptRefs(modules: FlowModule[]): string[] { + const unresolvedRefs: string[] = [] + + function checkModule(module: FlowModule) { + if (module.value.type === 'rawscript' && module.value.content) { + const match = module.value.content.match(/^inline_script\.(.+)$/) + if (match) { + unresolvedRefs.push(match[1]) + } + } else if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') { + if (module.value.modules) { + module.value.modules.forEach(checkModule) + } + } else if (module.value.type === 'branchone') { + if (module.value.branches) { + module.value.branches.forEach((branch) => { + branch.modules?.forEach(checkModule) + }) + } + if (module.value.default) { + module.value.default.forEach(checkModule) + } + } else if (module.value.type === 'branchall') { + if (module.value.branches) { + module.value.branches.forEach((branch) => { + branch.modules?.forEach(checkModule) + }) + } + } else if (module.value.type === 'aiagent') { + // Check AI agent tools + if (module.value.tools) { + for (const tool of module.value.tools) { + if ( + tool.value && + 'tool_type' in tool.value && + tool.value.tool_type === 'flowmodule' && + 'type' in tool.value && + tool.value.type === 'rawscript' && + 'content' in tool.value && + tool.value.content + ) { + const match = (tool.value.content as string).match(/^inline_script\.(.+)$/) + if (match) { + unresolvedRefs.push(match[1]) + } + } + } + } + } + } + + modules.forEach(checkModule) + return unresolvedRefs +} diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlow.json b/frontend/src/lib/components/copilot/chat/flow/openFlow.json new file mode 100644 index 0000000000..9815a90252 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/flow/openFlow.json @@ -0,0 +1 @@ +{"openapi":"3.0.3","info":{"version":"1.583.3","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"number","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"flow_env":{"type":"object","description":"Environment variables available to all steps","additionalProperties":{"type":"string"}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","description":"Custom error message shown when stopping"}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"}],"discriminator":{"propertyName":"type"}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type"}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake"]},"access_type":{"type":"string","description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"}]},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/InputTransform"},"output_type":{"$ref":"#/components/schemas/InputTransform"},"user_message":{"$ref":"#/components/schemas/InputTransform"},"system_prompt":{"$ref":"#/components/schemas/InputTransform"},"streaming":{"$ref":"#/components/schemas/InputTransform"},"messages_context_length":{"$ref":"#/components/schemas/InputTransform"},"output_schema":{"$ref":"#/components/schemas/InputTransform"},"user_images":{"$ref":"#/components/schemas/InputTransform"},"max_completion_tokens":{"$ref":"#/components/schemas/InputTransform"},"temperature":{"$ref":"#/components/schemas/InputTransform"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}}} \ No newline at end of file diff --git a/frontend/src/lib/components/copilot/chat/flow/utils.ts b/frontend/src/lib/components/copilot/chat/flow/utils.ts index 2f9f48007c..0fec4c6c8c 100644 --- a/frontend/src/lib/components/copilot/chat/flow/utils.ts +++ b/frontend/src/lib/components/copilot/chat/flow/utils.ts @@ -1,17 +1,31 @@ import { dfs } from '$lib/components/flows/previousResults' -import type { OpenFlow } from '$lib/gen' -import type { AIModuleAction } from './core' +import type { FlowModule, OpenFlow } from '$lib/gen' -export function getIndexInNestedModules(flow: OpenFlow, id: string) { +// Helper to find module by ID in a flow +export function getModuleById(flow: OpenFlow, moduleId: string): FlowModule | undefined { + const allModules = dfs(moduleId, flow, false) + return allModules[0] +} + +export function getIndexInNestedModules( + flow: OpenFlow, + id: string +): { index: number; modules: FlowModule[] } | null { const accessingModules = dfs(id, flow, true).reverse() + if (accessingModules.length === 0) { + // Module not found in flow + return null + } + let parent = flow.value.modules let lastIndex = -1 for (const [ai, am] of accessingModules.entries()) { const index = parent.findIndex((m) => m.id === am.id) if (index === -1) { - throw new Error(`Module not found: ${am.id} in ${parent.map((m) => m.id).join(', ')}`) + // Module no longer exists in expected location (may have been deleted with parent) + return null } lastIndex = index @@ -33,18 +47,18 @@ export function getIndexInNestedModules(flow: OpenFlow, id: string) { b.modules.some((m) => m.id === accessingModules[ai + 1].id) ) if (branchIdx === -1) { - throw new Error( - `Branch not found: ${am.id} in ${parent[index].value.branches.map((b) => b.modules.map((m) => m.id).join(', ')).join(';')}` - ) + // Module no longer exists in branch (may have been deleted) + return null } parent = parent[index].value.branches[branchIdx].modules } else { - throw new Error('Module is not a for loop or branch') + // Unexpected module type in path + return null } } if (lastIndex === -1) { - throw new Error('Module not found, should have been caught earlier') + return null } return { @@ -53,7 +67,11 @@ export function getIndexInNestedModules(flow: OpenFlow, id: string) { } } export function getNestedModules(flow: OpenFlow, id: string, branchIndex?: number) { - const { index, modules } = getIndexInNestedModules(flow, id) + const result = getIndexInNestedModules(flow, id) + if (!result) { + throw new Error(`Module not found: ${id}`) + } + const { index, modules } = result // we know index is correct because we've already checked it in getIndexInNestedModules const module = modules[index] @@ -84,46 +102,716 @@ export function getNestedModules(flow: OpenFlow, id: string, branchIndex?: numbe } } -export function aiModuleActionToBgColor(action: AIModuleAction | undefined) { - switch (action) { - case 'modified': - return '!bg-orange-200 dark:!bg-orange-800' - case 'added': - return '!bg-green-200 dark:!bg-green-800' - case 'removed': - return '!bg-red-200/50 dark:!bg-red-800/50' - case 'shadowed': - return '!bg-gray-200/30 dark:!bg-gray-800/30 !opacity-50' - default: - return '' +/** + * Recursively resolves all $ref references in a JSON Schema by inlining them. + * This ensures the schema is fully self-contained for AI providers that don't + * support external references or have strict schema validation (e.g., Google/Gemini). + * + * @param schema - The schema object to resolve + * @param rootSchema - The root schema document containing all definitions + * @param visited - Set of visited $ref paths to prevent infinite recursion + * @returns Fully resolved schema with all $ref references inlined + */ +export function resolveSchemaRefs(schema: any, rootSchema: any, visited = new Set()): any { + if (!schema || typeof schema !== 'object') return schema + + // Handle $ref + if (schema.$ref) { + const refPath = schema.$ref.replace('#/', '').split('/') + + // Prevent infinite recursion with circular refs + if (visited.has(schema.$ref)) { + return { type: 'object' } // Fallback for circular refs + } + visited.add(schema.$ref) + + let resolved = rootSchema + for (const part of refPath) { + resolved = resolved[part] + } + + // Recursively resolve the referenced schema + return resolveSchemaRefs(resolved, rootSchema, new Set(visited)) } -} -export function aiModuleActionToBorderColor(action: AIModuleAction | undefined) { - switch (action) { - case 'modified': - return '!border-orange-300 dark:!border-orange-700' - case 'added': - return '!border-green-400 dark:!border-green-700' - case 'removed': - return '!border-red-300 dark:!border-red-700' - case 'shadowed': - return '!border-gray-300 dark:!border-gray-600' - default: - return '' + + // Handle arrays + if (Array.isArray(schema)) { + return schema.map((item) => resolveSchemaRefs(item, rootSchema, visited)) } + + // Handle objects - recursively process all properties + const result: any = {} + for (const key in schema) { + result[key] = resolveSchemaRefs(schema[key], rootSchema, visited) + } + return result } -export function aiModuleActionToTextColor(action: AIModuleAction | undefined) { - switch (action) { - case 'modified': - return '!text-orange-800 dark:!text-orange-200' - case 'added': - return '!text-green-800 dark:!text-green-200' - case 'removed': - return '!text-red-800 dark:!text-red-200' - case 'shadowed': - return '!text-gray-600 dark:!text-gray-400' - default: - return '' +/** + * Recursively collects all module IDs from a module and its nested structures + */ +export function collectAllModuleIds(module: FlowModule): string[] { + const ids: string[] = [module.id] + + if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') { + if (module.value.modules) { + for (const nested of module.value.modules) { + ids.push(...collectAllModuleIds(nested)) + } + } + } else if (module.value.type === 'branchone') { + if (module.value.branches) { + for (const branch of module.value.branches) { + if (branch.modules) { + for (const nested of branch.modules) { + ids.push(...collectAllModuleIds(nested)) + } + } + } + } + if (module.value.default) { + for (const nested of module.value.default) { + ids.push(...collectAllModuleIds(nested)) + } + } + } else if (module.value.type === 'branchall') { + if (module.value.branches) { + for (const branch of module.value.branches) { + if (branch.modules) { + for (const nested of branch.modules) { + ids.push(...collectAllModuleIds(nested)) + } + } + } + } + } else if (module.value.type === 'aiagent') { + if (module.value.tools) { + for (const tool of module.value.tools) { + ids.push(tool.id) + } + } } + + return ids +} + +/** + * Recursively finds a module by ID in the flow structure + */ +export function findModuleInFlow(modules: FlowModule[], id: string): FlowModule | undefined { + for (const module of modules) { + if (module.id === id) { + return module + } + + // Search in nested structures + if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') { + if (module.value.modules) { + const found = findModuleInFlow(module.value.modules, id) + if (found) return found + } + } else if (module.value.type === 'branchone') { + if (module.value.branches) { + for (const branch of module.value.branches) { + if (branch.modules) { + const found = findModuleInFlow(branch.modules, id) + if (found) return found + } + } + } + if (module.value.default) { + const found = findModuleInFlow(module.value.default, id) + if (found) return found + } + } else if (module.value.type === 'branchall') { + if (module.value.branches) { + for (const branch of module.value.branches) { + if (branch.modules) { + const found = findModuleInFlow(branch.modules, id) + if (found) return found + } + } + } + } else if (module.value.type === 'aiagent') { + // Search in AI agent tools + if (module.value.tools) { + for (const tool of module.value.tools) { + if (tool.id === id) { + // Return a pseudo-FlowModule for compatibility + return { id: tool.id, value: tool.value, summary: tool.summary } as FlowModule + } + } + } + } + } + return undefined +} + +/** + * Recursively removes a module by ID from the flow structure + * Returns the updated modules array + */ +export function removeModuleFromFlow(modules: FlowModule[], id: string): FlowModule[] { + const result: FlowModule[] = [] + + for (const module of modules) { + if (module.id === id) { + // Skip this module (remove it) + continue + } + + const newModule = { ...module } + + // Recursively remove from nested structures + if (newModule.value.type === 'forloopflow' || newModule.value.type === 'whileloopflow') { + if (newModule.value.modules) { + newModule.value = { + ...newModule.value, + modules: removeModuleFromFlow(newModule.value.modules, id) + } + } + } else if (newModule.value.type === 'branchone') { + if (newModule.value.branches) { + newModule.value = { + ...newModule.value, + branches: newModule.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules ? removeModuleFromFlow(branch.modules, id) : [] + })) + } + } + if (newModule.value.default) { + newModule.value = { + ...newModule.value, + default: removeModuleFromFlow(newModule.value.default, id) + } + } + } else if (newModule.value.type === 'branchall') { + if (newModule.value.branches) { + newModule.value = { + ...newModule.value, + branches: newModule.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules ? removeModuleFromFlow(branch.modules, id) : [] + })) + } + } + } else if (newModule.value.type === 'aiagent') { + // Remove tool from AI agent's tools array + if (newModule.value.tools) { + newModule.value = { + ...newModule.value, + tools: newModule.value.tools.filter((tool) => tool.id !== id) + } + } + } + + result.push(newModule) + } + + return result +} + +/** + * Recursively removes a branch by index from a branchall/branchone container + * Returns the updated modules array + */ +export function removeBranchFromFlow( + modules: FlowModule[], + containerId: string, + branchIndex: number +): FlowModule[] { + return modules.map((module) => { + if (module.id === containerId) { + if (module.value.type === 'branchall') { + const branches = module.value.branches || [] + if (branchIndex < 0 || branchIndex >= branches.length) { + throw new Error(`Branch index ${branchIndex} out of bounds (0-${branches.length - 1})`) + } + return { + ...module, + value: { + ...module.value, + branches: branches.filter((_, i) => i !== branchIndex) + } + } as FlowModule + } + if (module.value.type === 'branchone') { + const branches = module.value.branches || [] + if (branchIndex < 0 || branchIndex >= branches.length) { + throw new Error(`Branch index ${branchIndex} out of bounds (0-${branches.length - 1})`) + } + return { + ...module, + value: { + ...module.value, + branches: branches.filter((_, i) => i !== branchIndex) + } + } as FlowModule + } + throw new Error(`Module '${containerId}' is not a branchall/branchone container`) + } + + // Recursively search nested structures + const newModule = { ...module } + if (newModule.value.type === 'forloopflow' || newModule.value.type === 'whileloopflow') { + if (newModule.value.modules) { + newModule.value = { + ...newModule.value, + modules: removeBranchFromFlow(newModule.value.modules, containerId, branchIndex) + } + } + } else if (newModule.value.type === 'branchone') { + if (newModule.value.branches) { + newModule.value = { + ...newModule.value, + branches: newModule.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules + ? removeBranchFromFlow(branch.modules, containerId, branchIndex) + : [] + })) + } + } + if (newModule.value.default) { + newModule.value = { + ...newModule.value, + default: removeBranchFromFlow(newModule.value.default, containerId, branchIndex) + } + } + } else if (newModule.value.type === 'branchall') { + if (newModule.value.branches) { + newModule.value = { + ...newModule.value, + branches: newModule.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules + ? removeBranchFromFlow(branch.modules, containerId, branchIndex) + : [] + })) + } + } + } + + return newModule + }) +} + +/** + * Parses a branch path string into navigation components + * Examples: 'branches.0' -> {type: 'branches', index: 0} + * 'default' -> {type: 'default'} + * 'modules' -> {type: 'modules'} + */ +export function parseBranchPath(path: string): { type: string; index?: number } { + if (path === 'default') { + return { type: 'default' } + } + if (path === 'modules') { + return { type: 'modules' } + } + if (path === 'tools') { + return { type: 'tools' } + } + + const match = path.match(/^(branches)\.(\d+)$/) + if (match) { + return { type: match[1], index: parseInt(match[2], 10) } + } + + throw new Error(`Invalid branch path: ${path}`) +} + +/** + * Gets the target array for module insertion based on insideId and branchPath + */ +function getTargetArray( + modules: FlowModule[], + insideId: string, + branchPath: string +): FlowModule[] | undefined { + const container = findModuleInFlow(modules, insideId) + if (!container) { + return undefined + } + + const parsed = parseBranchPath(branchPath) + + if (container.value.type === 'forloopflow' || container.value.type === 'whileloopflow') { + if (parsed.type === 'modules') { + return container.value.modules || [] + } + throw new Error(`Invalid branchPath '${branchPath}' for loop module. Use 'modules'`) + } else if (container.value.type === 'branchone') { + if (parsed.type === 'branches' && parsed.index !== undefined) { + return container.value.branches?.[parsed.index]?.modules + } else if (parsed.type === 'default') { + return container.value.default + } + throw new Error( + `Invalid branchPath '${branchPath}' for branchone module. Use 'branches.N' or 'default'` + ) + } else if (container.value.type === 'branchall') { + if (parsed.type === 'branches' && parsed.index !== undefined) { + return container.value.branches?.[parsed.index]?.modules + } + throw new Error(`Invalid branchPath '${branchPath}' for branchall module. Use 'branches.N'`) + } else if (container.value.type === 'aiagent') { + if (parsed.type === 'tools') { + // Return tools array (AgentTool[]), caller handles the different structure + return (container.value.tools as any) || [] + } + throw new Error(`Invalid branchPath '${branchPath}' for aiagent module. Use 'tools'`) + } + + throw new Error(`Module '${insideId}' is not a container type`) +} + +/** + * Updates a nested array within a container module + */ +function updateNestedArray( + module: FlowModule, + branchPath: string, + updatedArray: FlowModule[] +): FlowModule { + const parsed = parseBranchPath(branchPath) + const newModule = { ...module } + + if (newModule.value.type === 'forloopflow' || newModule.value.type === 'whileloopflow') { + if (parsed.type === 'modules') { + newModule.value = { + ...newModule.value, + modules: updatedArray + } + } + } else if (newModule.value.type === 'branchone') { + if (parsed.type === 'branches' && parsed.index !== undefined && newModule.value.branches) { + const newBranches = [...newModule.value.branches] + newBranches[parsed.index] = { + ...newBranches[parsed.index], + modules: updatedArray + } + newModule.value = { + ...newModule.value, + branches: newBranches + } + } else if (parsed.type === 'default') { + newModule.value = { + ...newModule.value, + default: updatedArray + } + } + } else if (newModule.value.type === 'branchall') { + if (parsed.type === 'branches' && parsed.index !== undefined && newModule.value.branches) { + const newBranches = [...newModule.value.branches] + newBranches[parsed.index] = { + ...newBranches[parsed.index], + modules: updatedArray + } + newModule.value = { + ...newModule.value, + branches: newBranches + } + } + } else if (newModule.value.type === 'aiagent') { + if (parsed.type === 'tools') { + // Note: updatedArray is actually AgentTool[] when dealing with AI agents + newModule.value = { + ...newModule.value, + tools: updatedArray as any + } + } + } + + return newModule +} + +/** + * Recursively adds a module to the flow structure + */ +export function addModuleToFlow( + modules: FlowModule[], + afterId: string | null, + insideId: string | null, + branchPath: string | null, + newModule: FlowModule +): FlowModule[] { + // Case 1a: Adding a NEW branch to branchall/branchone (insideId set, branchPath null) + if (insideId && branchPath === null) { + return modules.map((module) => { + if (module.id === insideId) { + // Adding a new branch to branchall + if (module.value.type === 'branchall') { + const newBranch = { + summary: (newModule as any).summary || '', + skip_failure: (newModule as any).skip_failure ?? false, + modules: (newModule as any).modules || [] + } + return { + ...module, + value: { + ...module.value, + branches: [...(module.value.branches || []), newBranch] + } + } as FlowModule + } + // Adding a new branch to branchone + if (module.value.type === 'branchone') { + const newBranch = { + summary: (newModule as any).summary || '', + expr: (newModule as any).expr || 'false', + modules: (newModule as any).modules || [] + } + return { + ...module, + value: { + ...module.value, + branches: [...(module.value.branches || []), newBranch] + } + } as FlowModule + } + throw new Error( + `Cannot add branch to module '${insideId}': branchPath=null is only valid for branchall/branchone containers` + ) + } + + // Recursively search nested structures for the target container + const newModuleCopy = { ...module } + if ( + newModuleCopy.value.type === 'forloopflow' || + newModuleCopy.value.type === 'whileloopflow' + ) { + if (newModuleCopy.value.modules) { + newModuleCopy.value = { + ...newModuleCopy.value, + modules: addModuleToFlow( + newModuleCopy.value.modules, + afterId, + insideId, + branchPath, + newModule + ) + } + } + } else if (newModuleCopy.value.type === 'branchone') { + if (newModuleCopy.value.branches) { + newModuleCopy.value = { + ...newModuleCopy.value, + branches: newModuleCopy.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules + ? addModuleToFlow(branch.modules, afterId, insideId, branchPath, newModule) + : [] + })) + } + } + if (newModuleCopy.value.default) { + newModuleCopy.value = { + ...newModuleCopy.value, + default: addModuleToFlow( + newModuleCopy.value.default, + afterId, + insideId, + branchPath, + newModule + ) + } + } + } else if (newModuleCopy.value.type === 'branchall') { + if (newModuleCopy.value.branches) { + newModuleCopy.value = { + ...newModuleCopy.value, + branches: newModuleCopy.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules + ? addModuleToFlow(branch.modules, afterId, insideId, branchPath, newModule) + : [] + })) + } + } + } + return newModuleCopy + }) + } + + // Case 1b: Adding inside a container (insideId + branchPath both set) + if (insideId && branchPath) { + return modules.map((module) => { + if (module.id === insideId) { + // Special handling for AI agent tools + if (module.value.type === 'aiagent' && branchPath === 'tools') { + // For AI agents, newModule structure is { id, summary, value: { tool_type, ...FlowModuleValue } } + // The value should already include tool_type from the caller + const newTool = { + id: newModule.id, + summary: newModule.summary, + value: newModule.value as any + } + return { + ...module, + value: { + ...module.value, + tools: [...(module.value.tools || []), newTool] + } + } as FlowModule + } + + const targetArray = getTargetArray(modules, insideId, branchPath) + if (!targetArray) { + throw new Error( + `Cannot find target array for insideId '${insideId}' with branchPath '${branchPath}'` + ) + } + const updatedArray = + afterId !== null + ? addModuleToFlow(targetArray, afterId, null, null, newModule) + : [newModule, ...targetArray] // afterId null = insert at beginning + return updateNestedArray(module, branchPath, updatedArray) + } + + // Recursively search nested structures + const newModuleCopy = { ...module } + if ( + newModuleCopy.value.type === 'forloopflow' || + newModuleCopy.value.type === 'whileloopflow' + ) { + if (newModuleCopy.value.modules) { + newModuleCopy.value = { + ...newModuleCopy.value, + modules: addModuleToFlow( + newModuleCopy.value.modules, + afterId, + insideId, + branchPath, + newModule + ) + } + } + } else if (newModuleCopy.value.type === 'branchone') { + if (newModuleCopy.value.branches) { + newModuleCopy.value = { + ...newModuleCopy.value, + branches: newModuleCopy.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules + ? addModuleToFlow(branch.modules, afterId, insideId, branchPath, newModule) + : [] + })) + } + } + if (newModuleCopy.value.default) { + newModuleCopy.value = { + ...newModuleCopy.value, + default: addModuleToFlow( + newModuleCopy.value.default, + afterId, + insideId, + branchPath, + newModule + ) + } + } + } else if (newModuleCopy.value.type === 'branchall') { + if (newModuleCopy.value.branches) { + newModuleCopy.value = { + ...newModuleCopy.value, + branches: newModuleCopy.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules + ? addModuleToFlow(branch.modules, afterId, insideId, branchPath, newModule) + : [] + })) + } + } + } + + return newModuleCopy + }) + } + + // Case 2: Adding at current level after a specific module + if (afterId !== null) { + const result: FlowModule[] = [] + for (const module of modules) { + result.push(module) + if (module.id === afterId) { + result.push(newModule) + } + } + return result + } + + // Case 3: afterId is null - insert at the beginning + return [newModule, ...modules] +} + +/** + * Recursively replaces a module by ID + */ +export function replaceModuleInFlow( + modules: FlowModule[], + id: string, + newModule: FlowModule +): FlowModule[] { + return modules.map((module) => { + if (module.id === id) { + return { ...newModule, id } // Ensure ID remains the same + } + + const newModuleCopy = { ...module } + + // Recursively replace in nested structures + if ( + newModuleCopy.value.type === 'forloopflow' || + newModuleCopy.value.type === 'whileloopflow' + ) { + if (newModuleCopy.value.modules) { + newModuleCopy.value = { + ...newModuleCopy.value, + modules: replaceModuleInFlow(newModuleCopy.value.modules, id, newModule) + } + } + } else if (newModuleCopy.value.type === 'branchone') { + if (newModuleCopy.value.branches) { + newModuleCopy.value = { + ...newModuleCopy.value, + branches: newModuleCopy.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules ? replaceModuleInFlow(branch.modules, id, newModule) : [] + })) + } + } + if (newModuleCopy.value.default) { + newModuleCopy.value = { + ...newModuleCopy.value, + default: replaceModuleInFlow(newModuleCopy.value.default, id, newModule) + } + } + } else if (newModuleCopy.value.type === 'branchall') { + if (newModuleCopy.value.branches) { + newModuleCopy.value = { + ...newModuleCopy.value, + branches: newModuleCopy.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules ? replaceModuleInFlow(branch.modules, id, newModule) : [] + })) + } + } + } else if (newModuleCopy.value.type === 'aiagent') { + // Replace tool in AI agent's tools array + if (newModuleCopy.value.tools) { + newModuleCopy.value = { + ...newModuleCopy.value, + tools: newModuleCopy.value.tools.map((tool) => + tool.id === id + ? { id, summary: newModule.summary, value: newModule.value as any } + : tool + ) + } + } + } + + return newModuleCopy + }) } diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 26c7e11cf8..b86dd6c5a6 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -3,6 +3,18 @@ import type { ChatCompletionMessageFunctionToolCall, ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs' + +/** + * Special module IDs used throughout the flow system + */ +export const SPECIAL_MODULE_IDS = { + /** The flow input schema node */ + INPUT: 'Input', + /** The preprocessor module that runs before the flow */ + PREPROCESSOR: 'preprocessor', + /** The failure handler module */ + FAILURE: 'failure' +} as const import { get } from 'svelte/store' import type { CodePieceElement, ContextElement, FlowModuleCodePieceElement } from './context' import { workspaceStore } from '$lib/stores' @@ -11,7 +23,6 @@ import type { FunctionParameters } from 'openai/resources/shared.mjs' import { z } from 'zod' import { ScriptService, JobService, type CompletedJob, type FlowModule } from '$lib/gen' import { scriptLangToEditorLang } from '$lib/scripts' -import YAML from 'yaml' import { getCurrentModel } from '$lib/aiStore' // Prettify function for code arguments - extracts and formats code from JSON @@ -45,9 +56,91 @@ function prettifyCodeArguments(content: string): string { return codeContent } +// Prettify function for set_module_code - extracts code from moduleId/code JSON +function prettifySetModuleCode(content: string): string { + let codeContent = content + + if (typeof content === 'string' && content.trim().startsWith('{')) { + try { + const parsed = JSON.parse(content) + if (parsed.code) { + codeContent = parsed.code + } + } catch { + // If JSON is incomplete during streaming, try to extract code property manually + const codeMatch = content.match(/"code"\s*:\s*"([\s\S]*?)(?:"\s*}?\s*$|$)/) + if (codeMatch) { + codeContent = codeMatch[1] + } + } + } + + // Convert escape sequences + codeContent = codeContent.replace(/\\n/g, '\n') + codeContent = codeContent.replace(/\\t/g, '\t') + codeContent = codeContent.replace(/\\"/g, '"') + codeContent = codeContent.replace(/\\\\/g, '\\') + + return codeContent +} + +// Prettify function for module value JSON - extracts the 'value' property and formats it +function prettifyModuleValue(content: string): string { + try { + const parsed = JSON.parse(content) + // Extract just the 'value' property (the actual module definition) + if (parsed.value) { + return JSON.stringify(parsed.value, null, 2) + } + return JSON.stringify(parsed, null, 2) + } catch { + // If JSON is incomplete during streaming, try to extract the value property manually + const valueMatch = content.match(/"value"\s*:\s*(\{[\s\S]*)$/) + if (valueMatch) { + let valueContent = valueMatch[1] + // Try to parse and format the extracted value + try { + // Find the matching closing brace for the value object + let braceCount = 0 + let endIndex = 0 + for (let i = 0; i < valueContent.length; i++) { + if (valueContent[i] === '{') braceCount++ + else if (valueContent[i] === '}') braceCount-- + if (braceCount === 0) { + endIndex = i + 1 + break + } + } + if (endIndex > 0) { + const valueJson = valueContent.substring(0, endIndex) + const parsed = JSON.parse(valueJson) + return JSON.stringify(parsed, null, 2) + } + } catch { + // If parsing fails, just unescape and return the extracted value content + valueContent = valueContent.replace(/\\n/g, '\n') + valueContent = valueContent.replace(/\\t/g, '\t') + valueContent = valueContent.replace(/\\"/g, '"') + valueContent = valueContent.replace(/\\\\/g, '\\') + return valueContent + } + } + // Fallback: just unescape and return + let result = content + result = result.replace(/\\n/g, '\n') + result = result.replace(/\\t/g, '\t') + result = result.replace(/\\"/g, '"') + result = result.replace(/\\\\/g, '\\') + return result + } +} + // Map of tool names to their prettify functions export const TOOL_PRETTIFY_MAP: Record string> = { - edit_code: prettifyCodeArguments + edit_code: prettifyCodeArguments, + set_module_code: prettifySetModuleCode, + add_module: prettifyModuleValue, + modify_module: prettifyModuleValue } export interface ContextStringResult { @@ -148,7 +241,7 @@ export function applyCodePiecesToFlowModules( } } - return YAML.stringify(modifiedModules) + return JSON.stringify(modifiedModules, null, 2) } export function buildContextString(selectedContext: ContextElement[]): string { @@ -363,7 +456,11 @@ export async function processToolCall({ error: 'An error occurred while calling the tool' }) const errorMessage = - typeof err === 'string' ? err : 'An error occurred while calling the tool' + typeof err === 'object' && 'message' in err + ? err.message + : typeof err === 'string' + ? err + : 'An error occurred while calling the tool' result = `Error while calling tool: ${errorMessage}` } const toAdd = { diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index c74cd2f8ff..5ac32889f4 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -244,34 +244,38 @@ export async function fetchAvailableModels( } export function getModelMaxTokens(provider: AIProvider, model: string) { - if (model.startsWith('gpt-5')) { + if (model.includes('gpt-5')) { return 128000 } else if ((provider === 'azure_openai' || provider === 'openai') && model.startsWith('o')) { return 100000 - } else if (model.startsWith('claude-sonnet') || model.startsWith('gemini-2.5')) { + } else if ( + model.includes('claude-sonnet') || + model.includes('gemini-2.5') || + model.includes('claude-haiku') + ) { return 64000 - } else if (model.startsWith('gpt-4.1')) { + } else if (model.includes('gpt-4.1')) { return 32768 - } else if (model.startsWith('claude-opus')) { + } else if (model.includes('claude-opus')) { return 32000 - } else if (model.startsWith('gpt-4o') || model.startsWith('codestral')) { + } else if (model.includes('gpt-4o') || model.includes('codestral')) { return 16384 - } else if (model.startsWith('gpt-4-turbo') || model.startsWith('gpt-3.5')) { + } else if (model.includes('gpt-4-turbo') || model.includes('gpt-3.5')) { return 4096 } return 8192 } export function getModelContextWindow(model: string) { - if (model.startsWith('gpt-4.1') || model.startsWith('gemini')) { + if (model.includes('gpt-4.1') || model.includes('gemini')) { return 1000000 - } else if (model.startsWith('gpt-5')) { + } else if (model.includes('gpt-5')) { return 400000 - } else if (model.startsWith('gpt-4o') || model.startsWith('llama-3.3')) { + } else if (model.includes('gpt-4o') || model.includes('llama-3.3')) { return 128000 - } else if (model.startsWith('claude') || model.startsWith('o4-mini') || model.startsWith('o3')) { + } else if (model.includes('claude') || model.includes('o4-mini') || model.includes('o3')) { return 200000 - } else if (model.startsWith('codestral')) { + } else if (model.includes('codestral')) { return 32000 } else { return 128000 diff --git a/frontend/src/lib/components/flows/FlowEditor.svelte b/frontend/src/lib/components/flows/FlowEditor.svelte index 0d7be9c358..3312acd5b0 100644 --- a/frontend/src/lib/components/flows/FlowEditor.svelte +++ b/frontend/src/lib/components/flows/FlowEditor.svelte @@ -129,6 +129,7 @@ onDestroy(() => { aiChatManager.flowOptions = undefined + aiChatManager.saveAndClear() aiChatManager.changeMode(AIMode.NAVIGATOR) }) @@ -213,11 +214,12 @@ {suspendStatus} onOpenDetails={onOpenPreview} {previewOpen} + {flowModuleSchemaMap} /> {/if} {#if !disableAi} - + {/if} diff --git a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte index f5ca067eeb..8ebf167d51 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte @@ -36,6 +36,7 @@ suspendStatus?: StateStore> onOpenDetails?: () => void previewOpen?: boolean + flowModuleSchemaMap?: import('../map/FlowModuleSchemaMap.svelte').default } let { @@ -52,7 +53,8 @@ isOwner, suspendStatus, onOpenDetails, - previewOpen = false + previewOpen = false, + flowModuleSchemaMap = undefined }: Props = $props() const { @@ -72,6 +74,7 @@ const { showCaptureHint, triggersState, triggersCount } = getContext('TriggerContext') + function checkDup(modules: FlowModule[]): string | undefined { let seenModules: string[] = [] for (const m of modules) { @@ -104,6 +107,7 @@ on:applyArgs {onTestFlow} {previewOpen} + {flowModuleSchemaMap} /> {:else if selectedId === 'Result'} diff --git a/frontend/src/lib/components/flows/content/FlowInput.svelte b/frontend/src/lib/components/flows/content/FlowInput.svelte index 3d07aa1912..44322f9e94 100644 --- a/frontend/src/lib/components/flows/content/FlowInput.svelte +++ b/frontend/src/lib/components/flows/content/FlowInput.svelte @@ -55,9 +55,16 @@ disabled: boolean onTestFlow?: (conversationId?: string) => Promise previewOpen: boolean + flowModuleSchemaMap?: import('../map/FlowModuleSchemaMap.svelte').default } - let { noEditor, disabled, onTestFlow, previewOpen }: Props = $props() + let { + noEditor, + disabled, + onTestFlow, + previewOpen, + flowModuleSchemaMap = undefined + }: Props = $props() const { flowStore, flowStateStore, @@ -68,6 +75,12 @@ flowInputEditorState } = getContext('FlowEditorContext') + // Get diffManager from the graph + const diffManager = $derived(flowModuleSchemaMap?.getDiffManager()) + + // Use pending schema from diffManager when in diff mode, otherwise use flowStore + const effectiveSchema = $derived(diffManager?.currentInputSchema ?? flowStore.val.schema) + let chatInputEnabled = $state(Boolean(flowStore.val.value?.chat_input_enabled)) let shouldUseStreaming = $derived.by(() => { const modules = flowStore.val.value?.modules @@ -447,19 +460,22 @@ value: { type: 'aiagent', tools: [], - input_transforms: Object.keys(AI_AGENT_SCHEMA.properties ?? {}).reduce((accu, key) => { - if (key === 'user_message') { - accu[key] = { type: 'javascript', expr: 'flow_input.user_message' } - } else if (key === 'messages_context_length') { - accu[key] = { type: 'static', value: 10 } - } else { - accu[key] = { - type: 'static', - value: undefined + input_transforms: Object.keys(AI_AGENT_SCHEMA.properties ?? {}).reduce( + (accu, key) => { + if (key === 'user_message') { + accu[key] = { type: 'javascript', expr: 'flow_input.user_message' } + } else if (key === 'messages_context_length') { + accu[key] = { type: 'static', value: 10 } + } else { + accu[key] = { + type: 'static', + value: undefined + } } - } - return accu - }, {}) + return accu + }, + {} as AiAgent['input_transforms'] + ) } } ] @@ -806,7 +822,7 @@ {:else}
- +
{/if} diff --git a/frontend/src/lib/components/flows/flowDiff.test.ts b/frontend/src/lib/components/flows/flowDiff.test.ts new file mode 100644 index 0000000000..a2e2264255 --- /dev/null +++ b/frontend/src/lib/components/flows/flowDiff.test.ts @@ -0,0 +1,1519 @@ +import { describe, it, expect } from 'vitest' +import { buildFlowTimeline, hasInputSchemaChanged } from './flowDiff' +import type { FlowValue, RawScript, ForloopFlow, WhileloopFlow, BranchOne, BranchAll } from '$lib/gen' +import { + createRawScriptModule, + createIdentityModule, + createForloopModule, + createWhileloopModule, + createBranchOneModule, + createBranchAllModule, + createFlow, + createFlowWithSpecialModules, + expectModuleOrder +} from './flowDiff.testUtils' + +describe('buildFlowTimeline', () => { + describe('basic detection', () => { + it('returns empty actions for identical flows', () => { + const moduleA = createRawScriptModule('a', 'console.log("hello")') + const beforeFlow = createFlow([moduleA]) + const afterFlow = createFlow([moduleA]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(Object.keys(result.beforeActions)).toHaveLength(0) + expect(Object.keys(result.afterActions)).toHaveLength(0) + }) + + it('detects added module', () => { + const moduleA = createRawScriptModule('a', 'console.log("a")') + const moduleB = createRawScriptModule('b', 'console.log("b")') + const beforeFlow = createFlow([moduleA]) + const afterFlow = createFlow([moduleA, moduleB]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions).toEqual({}) + expect(result.afterActions).toEqual({ + b: { action: 'added', pending: false } + }) + }) + + it('detects removed module', () => { + const moduleA = createRawScriptModule('a', 'console.log("a")') + const moduleB = createRawScriptModule('b', 'console.log("b")') + const beforeFlow = createFlow([moduleA, moduleB]) + const afterFlow = createFlow([moduleA]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions).toEqual({ + b: { action: 'removed', pending: false } + }) + // By default markRemovedAsShadowed is false, so removed modules show as 'removed' in afterActions + expect(result.afterActions).toEqual({ + b: { action: 'removed', pending: false } + }) + }) + + it('detects modified module (same type, different content)', () => { + const moduleBeforeA = createRawScriptModule('a', 'console.log("before")') + const moduleAfterA = createRawScriptModule('a', 'console.log("after")') + const beforeFlow = createFlow([moduleBeforeA]) + const afterFlow = createFlow([moduleAfterA]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions).toEqual({ + a: { action: 'modified', pending: false } + }) + expect(result.afterActions).toEqual({ + a: { action: 'modified', pending: false } + }) + }) + + it('treats type change as removed + added (not modified)', () => { + const moduleBeforeA = createRawScriptModule('a', 'console.log("script")') + const moduleAfterA = createIdentityModule('a') + const beforeFlow = createFlow([moduleBeforeA]) + const afterFlow = createFlow([moduleAfterA]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions).toEqual({ + a: { action: 'removed', pending: false } + }) + // The new module is 'added', and the old module is kept in mergedFlow + // with a prefixed ID 'old__a' and marked as 'removed' + expect(result.afterActions['a']).toEqual({ action: 'added', pending: false }) + expect(result.afterActions['old__a']).toEqual({ action: 'removed', pending: false }) + }) + }) + + describe('options', () => { + it('markAsPending sets pending: true on all actions', () => { + const moduleA = createRawScriptModule('a', 'console.log("a")') + const moduleB = createRawScriptModule('b', 'console.log("b")') + const beforeFlow = createFlow([moduleA]) + const afterFlow = createFlow([moduleB]) + + const result = buildFlowTimeline(beforeFlow, afterFlow, { + markAsPending: true, + markRemovedAsShadowed: false + }) + + expect(result.beforeActions).toEqual({ + a: { action: 'removed', pending: true } + }) + expect(result.afterActions).toEqual({ + a: { action: 'removed', pending: true }, + b: { action: 'added', pending: true } + }) + }) + + it('markRemovedAsShadowed: true shows removed as shadowed in afterActions', () => { + const moduleA = createRawScriptModule('a', 'console.log("a")') + const moduleB = createRawScriptModule('b', 'console.log("b")') + const beforeFlow = createFlow([moduleA, moduleB]) + const afterFlow = createFlow([moduleA]) + + const result = buildFlowTimeline(beforeFlow, afterFlow, { + markAsPending: false, + markRemovedAsShadowed: true + }) + + expect(result.beforeActions).toEqual({ + b: { action: 'removed', pending: false } + }) + expect(result.afterActions).toEqual({ + b: { action: 'shadowed', pending: false } + }) + }) + + it('markRemovedAsShadowed: false shows removed as removed in afterActions', () => { + const moduleA = createRawScriptModule('a', 'console.log("a")') + const moduleB = createRawScriptModule('b', 'console.log("b")') + const beforeFlow = createFlow([moduleA, moduleB]) + const afterFlow = createFlow([moduleA]) + + const result = buildFlowTimeline(beforeFlow, afterFlow, { + markAsPending: false, + markRemovedAsShadowed: false + }) + + expect(result.beforeActions).toEqual({ + b: { action: 'removed', pending: false } + }) + expect(result.afterActions).toEqual({ + b: { action: 'removed', pending: false } + }) + }) + }) + + describe('mergedFlow', () => { + it('mergedFlow contains all modules from afterFlow', () => { + const moduleA = createRawScriptModule('a', 'console.log("a")') + const moduleB = createRawScriptModule('b', 'console.log("b")') + const beforeFlow = createFlow([moduleA]) + const afterFlow = createFlow([moduleA, moduleB]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'b']) + }) + + it('mergedFlow includes removed modules from beforeFlow', () => { + const moduleA = createRawScriptModule('a', 'console.log("a")') + const moduleB = createRawScriptModule('b', 'console.log("b")') + const beforeFlow = createFlow([moduleA, moduleB]) + const afterFlow = createFlow([moduleA]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'b']) + }) + }) + + describe('edge cases', () => { + it('handles empty flows', () => { + const beforeFlow = createFlow([]) + const afterFlow = createFlow([]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(Object.keys(result.beforeActions)).toHaveLength(0) + expect(Object.keys(result.afterActions)).toHaveLength(0) + expect(result.mergedFlow.modules).toHaveLength(0) + }) + + it('handles flow with undefined modules', () => { + const beforeFlow = {} + const afterFlow = {} + + const result = buildFlowTimeline(beforeFlow as FlowValue, afterFlow as FlowValue) + + expect(Object.keys(result.beforeActions)).toHaveLength(0) + expect(Object.keys(result.afterActions)).toHaveLength(0) + }) + + it('handles multiple changes at once', () => { + const moduleA = createRawScriptModule('a', 'original') + const moduleB = createRawScriptModule('b', 'to be removed') + const moduleAModified = createRawScriptModule('a', 'modified') + const moduleC = createRawScriptModule('c', 'newly added') + + const beforeFlow = createFlow([moduleA, moduleB]) + const afterFlow = createFlow([moduleAModified, moduleC]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // Module 'a' was modified + expect(result.beforeActions['a']).toEqual({ action: 'modified', pending: false }) + expect(result.afterActions['a']).toEqual({ action: 'modified', pending: false }) + + // Module 'b' was removed + expect(result.beforeActions['b']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['b']).toEqual({ action: 'removed', pending: false }) + + // Module 'c' was added + expect(result.afterActions['c']).toEqual({ action: 'added', pending: false }) + }) + }) + + describe('forloop operations', () => { + it('detects added module inside forloop', () => { + const innerA = createRawScriptModule('inner_a', 'step a') + const innerB = createRawScriptModule('inner_b', 'step b') + + const beforeLoop = createForloopModule('loop1', [innerA]) + const afterLoop = createForloopModule('loop1', [innerA, innerB]) + + const beforeFlow = createFlow([beforeLoop]) + const afterFlow = createFlow([afterLoop]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['inner_b']).toEqual({ action: 'added', pending: false }) + expect(result.beforeActions['inner_b']).toBeUndefined() + + // Verify mergedFlow contains the loop with both modules in correct order + expectModuleOrder(result.mergedFlow.modules ?? [], ['loop1']) + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1') + expect(mergedLoop?.value.type).toBe('forloopflow') + const loopModules = (mergedLoop?.value as ForloopFlow).modules + expectModuleOrder(loopModules, ['inner_a', 'inner_b']) + }) + + it('detects removed module from inside forloop', () => { + const innerA = createRawScriptModule('inner_a', 'step a') + const innerB = createRawScriptModule('inner_b', 'step b') + + const beforeLoop = createForloopModule('loop1', [innerA, innerB]) + const afterLoop = createForloopModule('loop1', [innerA]) + + const beforeFlow = createFlow([beforeLoop]) + const afterFlow = createFlow([afterLoop]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions['inner_b']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['inner_b']).toEqual({ action: 'removed', pending: false }) + // Verify removed module is in mergedFlow inside the loop at correct position + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1') + expect(mergedLoop?.value.type).toBe('forloopflow') + const loopModules = (mergedLoop?.value as ForloopFlow).modules + expectModuleOrder(loopModules, ['inner_a', 'inner_b']) + }) + + it('detects modified module inside forloop', () => { + const innerBefore = createRawScriptModule('inner_a', 'original code') + const innerAfter = createRawScriptModule('inner_a', 'modified code') + + const beforeLoop = createForloopModule('loop1', [innerBefore]) + const afterLoop = createForloopModule('loop1', [innerAfter]) + + const beforeFlow = createFlow([beforeLoop]) + const afterFlow = createFlow([afterLoop]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions['inner_a']).toEqual({ action: 'modified', pending: false }) + expect(result.afterActions['inner_a']).toEqual({ action: 'modified', pending: false }) + + // Verify mergedFlow contains the loop with the modified module + expectModuleOrder(result.mergedFlow.modules ?? [], ['loop1']) + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1') + const loopModules = (mergedLoop?.value as ForloopFlow).modules + expectModuleOrder(loopModules, ['inner_a']) + // Verify the modified content is present (from afterFlow) + const innerModule = loopModules.find((m) => m.id === 'inner_a') + expect((innerModule?.value as RawScript).content).toBe('modified code') + }) + + it('detects removal of entire forloop with nested children', () => { + const innerA = createRawScriptModule('inner_a', 'step a') + const innerB = createRawScriptModule('inner_b', 'step b') + const loop = createForloopModule('loop1', [innerA, innerB]) + const moduleC = createRawScriptModule('c', 'step c') + + const beforeFlow = createFlow([loop, moduleC]) + const afterFlow = createFlow([moduleC]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // The loop itself and all nested modules should be marked as removed + expect(result.beforeActions['loop1']).toEqual({ action: 'removed', pending: false }) + expect(result.beforeActions['inner_a']).toEqual({ action: 'removed', pending: false }) + expect(result.beforeActions['inner_b']).toEqual({ action: 'removed', pending: false }) + + // Verify mergedFlow contains the removed loop with all nested children at correct positions + expectModuleOrder(result.mergedFlow.modules ?? [], ['loop1', 'c']) + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1') + expect(mergedLoop?.value.type).toBe('forloopflow') + const loopModules = (mergedLoop?.value as ForloopFlow).modules + expectModuleOrder(loopModules, ['inner_a', 'inner_b']) + }) + + it('handles nested forloop inside forloop with changes at inner level', () => { + const deepInnerA = createRawScriptModule('deep_a', 'deep step') + const deepInnerB = createRawScriptModule('deep_b', 'new deep step') + + const innerLoopBefore = createForloopModule('inner_loop', [deepInnerA]) + const innerLoopAfter = createForloopModule('inner_loop', [deepInnerA, deepInnerB]) + + const outerLoopBefore = createForloopModule('outer_loop', [innerLoopBefore]) + const outerLoopAfter = createForloopModule('outer_loop', [innerLoopAfter]) + + const beforeFlow = createFlow([outerLoopBefore]) + const afterFlow = createFlow([outerLoopAfter]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // The new nested module is detected as added + expect(result.afterActions['deep_b']).toEqual({ action: 'added', pending: false }) + // Note: Container modules are marked as 'modified' because their nested content changed + // (deepEqual comparison includes nested modules array) + expect(result.beforeActions['outer_loop']).toEqual({ action: 'modified', pending: false }) + expect(result.beforeActions['inner_loop']).toEqual({ action: 'modified', pending: false }) + + // Verify mergedFlow preserves the outer_loop → inner_loop → [deep_a, deep_b] structure + expectModuleOrder(result.mergedFlow.modules ?? [], ['outer_loop']) + const mergedOuterLoop = result.mergedFlow.modules?.find((m) => m.id === 'outer_loop') + expect(mergedOuterLoop?.value.type).toBe('forloopflow') + + const outerLoopModules = (mergedOuterLoop?.value as ForloopFlow).modules + expectModuleOrder(outerLoopModules, ['inner_loop']) + const mergedInnerLoop = outerLoopModules.find((m) => m.id === 'inner_loop') + expect(mergedInnerLoop?.value.type).toBe('forloopflow') + + const innerLoopModules = (mergedInnerLoop?.value as ForloopFlow).modules + expectModuleOrder(innerLoopModules, ['deep_a', 'deep_b']) + }) + }) + + describe('whileloop operations', () => { + it('detects changes inside whileloop', () => { + const innerA = createRawScriptModule('while_inner_a', 'step a') + const innerB = createRawScriptModule('while_inner_b', 'step b') + + const beforeLoop = createWhileloopModule('while1', [innerA]) + const afterLoop = createWhileloopModule('while1', [innerA, innerB]) + + const beforeFlow = createFlow([beforeLoop]) + const afterFlow = createFlow([afterLoop]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['while_inner_b']).toEqual({ action: 'added', pending: false }) + + // Verify mergedFlow contains the whileloop with both modules in correct order + expectModuleOrder(result.mergedFlow.modules ?? [], ['while1']) + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'while1') + expect(mergedLoop?.value.type).toBe('whileloopflow') + const loopModules = (mergedLoop?.value as WhileloopFlow).modules + expectModuleOrder(loopModules, ['while_inner_a', 'while_inner_b']) + }) + + it('handles removal of module from whileloop and restores in mergedFlow', () => { + const innerA = createRawScriptModule('while_a', 'step a') + const innerB = createRawScriptModule('while_b', 'step b') + + const beforeLoop = createWhileloopModule('while1', [innerA, innerB]) + const afterLoop = createWhileloopModule('while1', [innerA]) + + const beforeFlow = createFlow([beforeLoop]) + const afterFlow = createFlow([afterLoop]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions['while_b']).toEqual({ action: 'removed', pending: false }) + // Verify mergedFlow contains the removed module at correct position + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'while1') + const loopModules = (mergedLoop?.value as WhileloopFlow).modules + expectModuleOrder(loopModules, ['while_a', 'while_b']) + }) + }) + + describe('branchone operations', () => { + it('detects added module in default branch', () => { + const defaultA = createRawScriptModule('default_a', 'default step a') + const defaultB = createRawScriptModule('default_b', 'default step b') + + const beforeBranch = createBranchOneModule('branch1', [defaultA], []) + const afterBranch = createBranchOneModule('branch1', [defaultA, defaultB], []) + + const beforeFlow = createFlow([beforeBranch]) + const afterFlow = createFlow([afterBranch]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['default_b']).toEqual({ action: 'added', pending: false }) + + // Verify mergedFlow.branch1.default contains both modules in correct order + expectModuleOrder(result.mergedFlow.modules ?? [], ['branch1']) + const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch1') + expect(mergedBranch?.value.type).toBe('branchone') + const defaultModules = (mergedBranch?.value as BranchOne).default + expectModuleOrder(defaultModules, ['default_a', 'default_b']) + }) + + it('detects removed module from conditional branch', () => { + const branchModuleA = createRawScriptModule('branch_a', 'branch step a') + const branchModuleB = createRawScriptModule('branch_b', 'branch step b') + + const beforeBranch = createBranchOneModule('branch1', [], [ + { expr: 'true', modules: [branchModuleA, branchModuleB] } + ]) + const afterBranch = createBranchOneModule('branch1', [], [ + { expr: 'true', modules: [branchModuleA] } + ]) + + const beforeFlow = createFlow([beforeBranch]) + const afterFlow = createFlow([afterBranch]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions['branch_b']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['branch_b']).toEqual({ action: 'removed', pending: false }) + + // Verify mergedFlow.branch1.branches[0].modules contains the removed branch_b at correct position + const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch1') + const branches = (mergedBranch?.value as BranchOne).branches + expect(branches).toHaveLength(1) + expectModuleOrder(branches[0].modules, ['branch_a', 'branch_b']) + }) + + it('detects removal of entire conditional branch', () => { + const defaultModule = createRawScriptModule('default_mod', 'default') + const branch1ModuleA = createRawScriptModule('b1_a', 'branch 1 a') + const branch2ModuleA = createRawScriptModule('b2_a', 'branch 2 a') + const branch2ModuleB = createRawScriptModule('b2_b', 'branch 2 b') + + const beforeBranch = createBranchOneModule('branch1', [defaultModule], [ + { expr: 'x > 0', modules: [branch1ModuleA] }, + { expr: 'x < 0', modules: [branch2ModuleA, branch2ModuleB] } // entire branch removed + ]) + const afterBranch = createBranchOneModule('branch1', [defaultModule], [ + { expr: 'x > 0', modules: [branch1ModuleA] } + ]) + + const beforeFlow = createFlow([beforeBranch]) + const afterFlow = createFlow([afterBranch]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // All modules from removed branch should be marked as removed + expect(result.beforeActions['b2_a']).toEqual({ action: 'removed', pending: false }) + expect(result.beforeActions['b2_b']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['b2_a']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['b2_b']).toEqual({ action: 'removed', pending: false }) + + // Verify mergedFlow structure - should have 2 branches with removed modules restored + expectModuleOrder(result.mergedFlow.modules ?? [], ['branch1']) + const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch1') + const branchValue = mergedBranch?.value as BranchOne + + // Default branch unchanged + expectModuleOrder(branchValue.default, ['default_mod']) + + // First branch unchanged + expectModuleOrder(branchValue.branches[0].modules, ['b1_a']) + + // Second branch should be restored with its modules + expect(branchValue.branches).toHaveLength(2) + expectModuleOrder(branchValue.branches[1].modules, ['b2_a', 'b2_b']) + }) + + it('detects changes across multiple branches simultaneously', () => { + const defaultModule = createRawScriptModule('default_mod', 'default') + const branch1ModuleA = createRawScriptModule('b1_a', 'branch 1 a') + const branch1ModuleB = createRawScriptModule('b1_b', 'branch 1 b - new') + const branch2ModuleA = createRawScriptModule('b2_a', 'branch 2 a - original') + const branch2ModuleAModified = createRawScriptModule('b2_a', 'branch 2 a - modified') + + const beforeBranch = createBranchOneModule('branch1', [defaultModule], [ + { expr: 'x > 0', modules: [branch1ModuleA] }, + { expr: 'x < 0', modules: [branch2ModuleA] } + ]) + const afterBranch = createBranchOneModule('branch1', [defaultModule], [ + { expr: 'x > 0', modules: [branch1ModuleA, branch1ModuleB] }, + { expr: 'x < 0', modules: [branch2ModuleAModified] } + ]) + + const beforeFlow = createFlow([beforeBranch]) + const afterFlow = createFlow([afterBranch]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // branch1ModuleB was added + expect(result.afterActions['b1_b']).toEqual({ action: 'added', pending: false }) + // branch2ModuleA was modified + expect(result.beforeActions['b2_a']).toEqual({ action: 'modified', pending: false }) + expect(result.afterActions['b2_a']).toEqual({ action: 'modified', pending: false }) + + // Verify mergedFlow preserves structure with correct ordering + expectModuleOrder(result.mergedFlow.modules ?? [], ['branch1']) + const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch1') + const branchValue = mergedBranch?.value as BranchOne + + // Check default branch + expectModuleOrder(branchValue.default, ['default_mod']) + + // Check first conditional branch (should have b1_a and b1_b in order) + expectModuleOrder(branchValue.branches[0].modules, ['b1_a', 'b1_b']) + + // Check second conditional branch (should have modified b2_a) + expectModuleOrder(branchValue.branches[1].modules, ['b2_a']) + const b2aModule = branchValue.branches[1].modules.find((m) => m.id === 'b2_a') + expect((b2aModule?.value as RawScript).content).toBe('branch 2 a - modified') + }) + + it('handles nested branch inside loop with changes', () => { + const branchInnerA = createRawScriptModule('nested_branch_a', 'nested a') + const branchInnerB = createRawScriptModule('nested_branch_b', 'nested b') + + const beforeBranch = createBranchOneModule('inner_branch', [branchInnerA], []) + const afterBranch = createBranchOneModule('inner_branch', [branchInnerA, branchInnerB], []) + + const beforeLoop = createForloopModule('outer_loop', [beforeBranch]) + const afterLoop = createForloopModule('outer_loop', [afterBranch]) + + const beforeFlow = createFlow([beforeLoop]) + const afterFlow = createFlow([afterLoop]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['nested_branch_b']).toEqual({ action: 'added', pending: false }) + + // Verify mergedFlow.outer_loop.inner_branch.default contains added nested_branch_b at correct position + expectModuleOrder(result.mergedFlow.modules ?? [], ['outer_loop']) + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'outer_loop') + const loopModules = (mergedLoop?.value as ForloopFlow).modules + expectModuleOrder(loopModules, ['inner_branch']) + const mergedBranch = loopModules.find((m) => m.id === 'inner_branch') + const branchDefault = (mergedBranch?.value as BranchOne).default + expectModuleOrder(branchDefault, ['nested_branch_a', 'nested_branch_b']) + }) + }) + + describe('branchall operations', () => { + it('detects changes in parallel branches', () => { + const parallel1A = createRawScriptModule('p1_a', 'parallel 1 a') + const parallel2A = createRawScriptModule('p2_a', 'parallel 2 a') + const parallel2B = createRawScriptModule('p2_b', 'parallel 2 b - new') + + const beforeBranchAll = createBranchAllModule('branchall1', [ + { modules: [parallel1A] }, + { modules: [parallel2A] } + ]) + const afterBranchAll = createBranchAllModule('branchall1', [ + { modules: [parallel1A] }, + { modules: [parallel2A, parallel2B] } + ]) + + const beforeFlow = createFlow([beforeBranchAll]) + const afterFlow = createFlow([afterBranchAll]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['p2_b']).toEqual({ action: 'added', pending: false }) + + // Verify mergedFlow.branchall1.branches have correct module order + expectModuleOrder(result.mergedFlow.modules ?? [], ['branchall1']) + const mergedBranchAll = result.mergedFlow.modules?.find((m) => m.id === 'branchall1') + expect(mergedBranchAll?.value.type).toBe('branchall') + const branches = (mergedBranchAll?.value as BranchAll).branches + expect(branches).toHaveLength(2) + // First branch unchanged + expectModuleOrder(branches[0].modules, ['p1_a']) + // Second branch has added module in correct order + expectModuleOrder(branches[1].modules, ['p2_a', 'p2_b']) + }) + + it('detects removal of entire branchall with all nested modules', () => { + const parallel1A = createRawScriptModule('p1_a', 'parallel 1') + const parallel2A = createRawScriptModule('p2_a', 'parallel 2') + const branchAll = createBranchAllModule('branchall1', [ + { modules: [parallel1A] }, + { modules: [parallel2A] } + ]) + const moduleC = createRawScriptModule('c', 'step c') + + const beforeFlow = createFlow([branchAll, moduleC]) + const afterFlow = createFlow([moduleC]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions['branchall1']).toEqual({ action: 'removed', pending: false }) + expect(result.beforeActions['p1_a']).toEqual({ action: 'removed', pending: false }) + expect(result.beforeActions['p2_a']).toEqual({ action: 'removed', pending: false }) + + // Verify mergedFlow contains the removed branchall with correct structure + expectModuleOrder(result.mergedFlow.modules ?? [], ['branchall1', 'c']) + const mergedBranchAll = result.mergedFlow.modules?.find((m) => m.id === 'branchall1') + expect(mergedBranchAll?.value.type).toBe('branchall') + const branches = (mergedBranchAll?.value as BranchAll).branches + expect(branches).toHaveLength(2) + expectModuleOrder(branches[0].modules, ['p1_a']) + expectModuleOrder(branches[1].modules, ['p2_a']) + }) + + it('handles removal from one parallel branch while adding to another', () => { + const parallel1A = createRawScriptModule('p1_a', 'parallel 1 a') + const parallel1B = createRawScriptModule('p1_b', 'parallel 1 b - to remove') + const parallel2A = createRawScriptModule('p2_a', 'parallel 2 a') + const parallel2B = createRawScriptModule('p2_b', 'parallel 2 b - new') + + const beforeBranchAll = createBranchAllModule('branchall1', [ + { modules: [parallel1A, parallel1B] }, + { modules: [parallel2A] } + ]) + const afterBranchAll = createBranchAllModule('branchall1', [ + { modules: [parallel1A] }, + { modules: [parallel2A, parallel2B] } + ]) + + const beforeFlow = createFlow([beforeBranchAll]) + const afterFlow = createFlow([afterBranchAll]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions['p1_b']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['p2_b']).toEqual({ action: 'added', pending: false }) + + // Verify mergedFlow.branchall1 has correct structure with all modules in order + expectModuleOrder(result.mergedFlow.modules ?? [], ['branchall1']) + const mergedBranchAll = result.mergedFlow.modules?.find((m) => m.id === 'branchall1') + const branches = (mergedBranchAll?.value as BranchAll).branches + expect(branches).toHaveLength(2) + + // First branch should have both p1_a and removed p1_b restored in order + expectModuleOrder(branches[0].modules, ['p1_a', 'p1_b']) + + // Second branch should have p2_a and added p2_b in order + expectModuleOrder(branches[1].modules, ['p2_a', 'p2_b']) + }) + }) + + describe('deep nesting', () => { + it('handles loop inside branch inside loop with modifications at each level', () => { + // Structure: outerLoop -> branch -> innerLoop -> scripts + const deepScript1 = createRawScriptModule('deep1', 'deep script 1') + const deepScript2 = createRawScriptModule('deep2', 'deep script 2') + const deepScript3 = createRawScriptModule('deep3', 'deep script 3 - new') + + const innerLoopBefore = createForloopModule('inner_loop', [deepScript1, deepScript2]) + const innerLoopAfter = createForloopModule('inner_loop', [deepScript1, deepScript3]) + + const branchBefore = createBranchOneModule('mid_branch', [innerLoopBefore], []) + const branchAfter = createBranchOneModule('mid_branch', [innerLoopAfter], []) + + const outerLoopBefore = createForloopModule('outer_loop', [branchBefore]) + const outerLoopAfter = createForloopModule('outer_loop', [branchAfter]) + + const beforeFlow = createFlow([outerLoopBefore]) + const afterFlow = createFlow([outerLoopAfter]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // deep2 was removed + expect(result.beforeActions['deep2']).toEqual({ action: 'removed', pending: false }) + // deep3 was added + expect(result.afterActions['deep3']).toEqual({ action: 'added', pending: false }) + // Note: Container modules are marked as 'modified' because their nested content changed + // (deepEqual comparison includes nested modules array) + expect(result.beforeActions['outer_loop']).toEqual({ action: 'modified', pending: false }) + expect(result.beforeActions['mid_branch']).toEqual({ action: 'modified', pending: false }) + expect(result.beforeActions['inner_loop']).toEqual({ action: 'modified', pending: false }) + + // Verify full structure with correct ordering at each level + expectModuleOrder(result.mergedFlow.modules ?? [], ['outer_loop']) + const mergedOuterLoop = result.mergedFlow.modules?.find((m) => m.id === 'outer_loop') + expect(mergedOuterLoop?.value.type).toBe('forloopflow') + + const outerLoopModules = (mergedOuterLoop?.value as ForloopFlow).modules + expectModuleOrder(outerLoopModules, ['mid_branch']) + const mergedMidBranch = outerLoopModules.find((m) => m.id === 'mid_branch') + expect(mergedMidBranch?.value.type).toBe('branchone') + + const midBranchDefault = (mergedMidBranch?.value as BranchOne).default + expectModuleOrder(midBranchDefault, ['inner_loop']) + const mergedInnerLoop = midBranchDefault.find((m) => m.id === 'inner_loop') + expect(mergedInnerLoop?.value.type).toBe('forloopflow') + + const innerLoopModules = (mergedInnerLoop?.value as ForloopFlow).modules + // deep1 is unchanged, deep2 was removed (but restored in mergedFlow), deep3 was added + expectModuleOrder(innerLoopModules, ['deep1', 'deep2', 'deep3']) + }) + + it('handles complex scenario with multiple nested structures and simultaneous changes', () => { + // Before: loop1 -> [scriptA, branch1 -> [scriptB, scriptC]] + // After: loop1 -> [scriptA_modified, branch1 -> [scriptB, scriptD]] + const scriptA = createRawScriptModule('a', 'script a original') + const scriptAModified = createRawScriptModule('a', 'script a modified') + const scriptB = createRawScriptModule('b', 'script b') + const scriptC = createRawScriptModule('c', 'script c - to remove') + const scriptD = createRawScriptModule('d', 'script d - new') + + const branchBefore = createBranchOneModule('branch1', [scriptB, scriptC], []) + const branchAfter = createBranchOneModule('branch1', [scriptB, scriptD], []) + + const loopBefore = createForloopModule('loop1', [scriptA, branchBefore]) + const loopAfter = createForloopModule('loop1', [scriptAModified, branchAfter]) + + const beforeFlow = createFlow([loopBefore]) + const afterFlow = createFlow([loopAfter]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // scriptA was modified + expect(result.beforeActions['a']).toEqual({ action: 'modified', pending: false }) + expect(result.afterActions['a']).toEqual({ action: 'modified', pending: false }) + // scriptC was removed + expect(result.beforeActions['c']).toEqual({ action: 'removed', pending: false }) + // scriptD was added + expect(result.afterActions['d']).toEqual({ action: 'added', pending: false }) + // scriptB unchanged + expect(result.beforeActions['b']).toBeUndefined() + expect(result.afterActions['b']).toBeUndefined() + + // Verify mergedFlow structure with correct ordering + expectModuleOrder(result.mergedFlow.modules ?? [], ['loop1']) + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1') + const loopModules = (mergedLoop?.value as ForloopFlow).modules + expectModuleOrder(loopModules, ['a', 'branch1']) + + // Modified scriptA should be in mergedFlow with updated content + const mergedA = loopModules.find((m) => m.id === 'a') + expect((mergedA?.value as RawScript).content).toBe('script a modified') + + // Branch1 should contain scriptB, removed c, and added d in correct order + const mergedBranch = loopModules.find((m) => m.id === 'branch1') + const branchDefault = (mergedBranch?.value as BranchOne).default + expectModuleOrder(branchDefault, ['b', 'c', 'd']) + }) + + it('preserves correct structure in mergedFlow for deeply nested removals', () => { + // Remove a script from inside a loop that is inside a branch + const deepScript = createRawScriptModule('deep', 'deep script to remove') + const keepScript = createRawScriptModule('keep', 'script to keep') + + const innerLoopBefore = createForloopModule('inner_loop', [deepScript, keepScript]) + const innerLoopAfter = createForloopModule('inner_loop', [keepScript]) + + const branchBefore = createBranchOneModule('branch', [innerLoopBefore], []) + const branchAfter = createBranchOneModule('branch', [innerLoopAfter], []) + + const beforeFlow = createFlow([branchBefore]) + const afterFlow = createFlow([branchAfter]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // Navigate into the merged flow structure and verify correct ordering + expectModuleOrder(result.mergedFlow.modules ?? [], ['branch']) + const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch') + + const branchDefault = (mergedBranch?.value as BranchOne).default + expectModuleOrder(branchDefault, ['inner_loop']) + const mergedInnerLoop = branchDefault.find((m) => m.id === 'inner_loop') + + const innerLoopModules = (mergedInnerLoop?.value as ForloopFlow).modules + // Both modules should be present in the merged flow in correct order + expectModuleOrder(innerLoopModules, ['deep', 'keep']) + }) + + it('handles branchall inside branchone with nested changes', () => { + const scriptInParallel1 = createRawScriptModule('par1', 'parallel 1') + const scriptInParallel2 = createRawScriptModule('par2', 'parallel 2') + const scriptInParallel3 = createRawScriptModule('par3', 'parallel 3 - new') + + const branchAllBefore = createBranchAllModule('parallel_section', [ + { modules: [scriptInParallel1] }, + { modules: [scriptInParallel2] } + ]) + const branchAllAfter = createBranchAllModule('parallel_section', [ + { modules: [scriptInParallel1] }, + { modules: [scriptInParallel2, scriptInParallel3] } + ]) + + const branchOneBefore = createBranchOneModule('outer_branch', [branchAllBefore], [ + { expr: 'x > 0', modules: [] } + ]) + const branchOneAfter = createBranchOneModule('outer_branch', [branchAllAfter], [ + { expr: 'x > 0', modules: [] } + ]) + + const beforeFlow = createFlow([branchOneBefore]) + const afterFlow = createFlow([branchOneAfter]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['par3']).toEqual({ action: 'added', pending: false }) + // Other modules unchanged + expect(result.beforeActions['par1']).toBeUndefined() + expect(result.beforeActions['par2']).toBeUndefined() + + // Verify full nested structure is preserved in mergedFlow with correct ordering + expectModuleOrder(result.mergedFlow.modules ?? [], ['outer_branch']) + const mergedBranchOne = result.mergedFlow.modules?.find((m) => m.id === 'outer_branch') + expect(mergedBranchOne?.value.type).toBe('branchone') + + const branchOneDefault = (mergedBranchOne?.value as BranchOne).default + expectModuleOrder(branchOneDefault, ['parallel_section']) + const mergedBranchAll = branchOneDefault.find((m) => m.id === 'parallel_section') + expect(mergedBranchAll?.value.type).toBe('branchall') + + const parallelBranches = (mergedBranchAll?.value as BranchAll).branches + expect(parallelBranches).toHaveLength(2) + // First parallel branch has par1 + expectModuleOrder(parallelBranches[0].modules, ['par1']) + // Second parallel branch has par2 and added par3 in correct order + expectModuleOrder(parallelBranches[1].modules, ['par2', 'par3']) + }) + }) + + describe('special modules', () => { + it('detects added failure_module', () => { + const moduleA = createRawScriptModule('a', 'main step') + const failureModule = createRawScriptModule('failure', 'handle failure') + + const beforeFlow = createFlowWithSpecialModules({ modules: [moduleA] }) + const afterFlow = createFlowWithSpecialModules({ + modules: [moduleA], + failure_module: failureModule + }) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['failure']).toEqual({ action: 'added', pending: false }) + expect(result.beforeActions['failure']).toBeUndefined() + + // Verify mergedFlow contains the failure_module + expect(result.mergedFlow.failure_module?.id).toBe('failure') + }) + + it('detects removed failure_module', () => { + const moduleA = createRawScriptModule('a', 'main step') + const failureModule = createRawScriptModule('failure', 'handle failure') + + const beforeFlow = createFlowWithSpecialModules({ + modules: [moduleA], + failure_module: failureModule + }) + const afterFlow = createFlowWithSpecialModules({ modules: [moduleA] }) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions['failure']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['failure']).toEqual({ action: 'removed', pending: false }) + + // Verify mergedFlow contains the removed failure_module + expect(result.mergedFlow.failure_module?.id).toBe('failure') + }) + + it('detects modified failure_module', () => { + const moduleA = createRawScriptModule('a', 'main step') + const failureModuleBefore = createRawScriptModule('failure', 'handle failure v1') + const failureModuleAfter = createRawScriptModule('failure', 'handle failure v2') + + const beforeFlow = createFlowWithSpecialModules({ + modules: [moduleA], + failure_module: failureModuleBefore + }) + const afterFlow = createFlowWithSpecialModules({ + modules: [moduleA], + failure_module: failureModuleAfter + }) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions['failure']).toEqual({ action: 'modified', pending: false }) + expect(result.afterActions['failure']).toEqual({ action: 'modified', pending: false }) + + // Verify mergedFlow contains the modified failure_module with new content + expect(result.mergedFlow.failure_module?.id).toBe('failure') + expect((result.mergedFlow.failure_module?.value as RawScript).content).toBe( + 'handle failure v2' + ) + }) + + it('detects added preprocessor_module', () => { + const moduleA = createRawScriptModule('a', 'main step') + const preprocessorModule = createRawScriptModule('preprocessor', 'preprocess input') + + const beforeFlow = createFlowWithSpecialModules({ modules: [moduleA] }) + const afterFlow = createFlowWithSpecialModules({ + modules: [moduleA], + preprocessor_module: preprocessorModule + }) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['preprocessor']).toEqual({ action: 'added', pending: false }) + expect(result.beforeActions['preprocessor']).toBeUndefined() + + // Verify mergedFlow contains the preprocessor_module + expect(result.mergedFlow.preprocessor_module?.id).toBe('preprocessor') + }) + + it('detects removed preprocessor_module', () => { + const moduleA = createRawScriptModule('a', 'main step') + const preprocessorModule = createRawScriptModule('preprocessor', 'preprocess input') + + const beforeFlow = createFlowWithSpecialModules({ + modules: [moduleA], + preprocessor_module: preprocessorModule + }) + const afterFlow = createFlowWithSpecialModules({ modules: [moduleA] }) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions['preprocessor']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['preprocessor']).toEqual({ action: 'removed', pending: false }) + + // Verify mergedFlow contains the removed preprocessor_module + expect(result.mergedFlow.preprocessor_module?.id).toBe('preprocessor') + }) + + it('detects changes to both failure and preprocessor modules simultaneously', () => { + const moduleA = createRawScriptModule('a', 'main step') + const failureModuleBefore = createRawScriptModule('failure', 'handle failure v1') + const failureModuleAfter = createRawScriptModule('failure', 'handle failure v2') + const preprocessorModule = createRawScriptModule('preprocessor', 'preprocess input') + + const beforeFlow = createFlowWithSpecialModules({ + modules: [moduleA], + failure_module: failureModuleBefore, + preprocessor_module: preprocessorModule + }) + const afterFlow = createFlowWithSpecialModules({ + modules: [moduleA], + failure_module: failureModuleAfter + // preprocessor_module removed + }) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // failure_module was modified + expect(result.beforeActions['failure']).toEqual({ action: 'modified', pending: false }) + expect(result.afterActions['failure']).toEqual({ action: 'modified', pending: false }) + + // preprocessor_module was removed + expect(result.beforeActions['preprocessor']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['preprocessor']).toEqual({ action: 'removed', pending: false }) + + // Verify mergedFlow structure + expect(result.mergedFlow.failure_module?.id).toBe('failure') + expect(result.mergedFlow.preprocessor_module?.id).toBe('preprocessor') + }) + }) + + describe('branchall entire branch removal', () => { + it('detects removal of entire parallel branch from branchall', () => { + const parallel1A = createRawScriptModule('p1_a', 'parallel 1 a') + const parallel1B = createRawScriptModule('p1_b', 'parallel 1 b') + const parallel2A = createRawScriptModule('p2_a', 'parallel 2 a') + + const beforeBranchAll = createBranchAllModule('branchall1', [ + { modules: [parallel1A, parallel1B] }, + { modules: [parallel2A] } // entire branch removed + ]) + const afterBranchAll = createBranchAllModule('branchall1', [ + { modules: [parallel1A, parallel1B] } + ]) + + const beforeFlow = createFlow([beforeBranchAll]) + const afterFlow = createFlow([afterBranchAll]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // Module from removed branch should be marked as removed + expect(result.beforeActions['p2_a']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['p2_a']).toEqual({ action: 'removed', pending: false }) + + // Modules from preserved branch should be unchanged + expect(result.beforeActions['p1_a']).toBeUndefined() + expect(result.beforeActions['p1_b']).toBeUndefined() + + // Verify mergedFlow structure - should have 2 branches with removed modules restored + expectModuleOrder(result.mergedFlow.modules ?? [], ['branchall1']) + const mergedBranchAll = result.mergedFlow.modules?.find((m) => m.id === 'branchall1') + const branches = (mergedBranchAll?.value as BranchAll).branches + + // First branch unchanged + expectModuleOrder(branches[0].modules, ['p1_a', 'p1_b']) + + // Second branch should be restored with its module + expect(branches).toHaveLength(2) + expectModuleOrder(branches[1].modules, ['p2_a']) + }) + + it('detects removal of multiple parallel branches from branchall', () => { + const p1_a = createRawScriptModule('p1_a', 'parallel 1') + const p2_a = createRawScriptModule('p2_a', 'parallel 2') + const p3_a = createRawScriptModule('p3_a', 'parallel 3') + + const beforeBranchAll = createBranchAllModule('branchall1', [ + { modules: [p1_a] }, + { modules: [p2_a] }, // removed + { modules: [p3_a] } // removed + ]) + const afterBranchAll = createBranchAllModule('branchall1', [{ modules: [p1_a] }]) + + const beforeFlow = createFlow([beforeBranchAll]) + const afterFlow = createFlow([afterBranchAll]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // Modules from removed branches should be marked as removed + expect(result.beforeActions['p2_a']).toEqual({ action: 'removed', pending: false }) + expect(result.beforeActions['p3_a']).toEqual({ action: 'removed', pending: false }) + + // Verify mergedFlow structure - should have 3 branches restored + const mergedBranchAll = result.mergedFlow.modules?.find((m) => m.id === 'branchall1') + const branches = (mergedBranchAll?.value as BranchAll).branches + + expect(branches).toHaveLength(3) + expectModuleOrder(branches[0].modules, ['p1_a']) + expectModuleOrder(branches[1].modules, ['p2_a']) + expectModuleOrder(branches[2].modules, ['p3_a']) + }) + }) + + describe('hasInputSchemaChanged', () => { + it('returns false for identical schemas', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + } + } + const beforeFlow = { schema } + const afterFlow = { schema: JSON.parse(JSON.stringify(schema)) } + + expect(hasInputSchemaChanged(beforeFlow, afterFlow)).toBe(false) + }) + + it('returns true for different schemas', () => { + const beforeFlow = { + schema: { + type: 'object', + properties: { + name: { type: 'string' } + } + } + } + const afterFlow = { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string' } + } + } + } + + expect(hasInputSchemaChanged(beforeFlow, afterFlow)).toBe(true) + }) + + it('returns false for undefined flows', () => { + expect(hasInputSchemaChanged(undefined, undefined)).toBe(false) + expect(hasInputSchemaChanged(undefined, { schema: {} })).toBe(false) + expect(hasInputSchemaChanged({ schema: {} }, undefined)).toBe(false) + }) + + it('returns true when schema is added', () => { + const beforeFlow = {} + const afterFlow = { schema: { type: 'object' } } + + expect(hasInputSchemaChanged(beforeFlow, afterFlow)).toBe(true) + }) + + it('returns true when schema is removed', () => { + const beforeFlow = { schema: { type: 'object' } } + const afterFlow = {} + + expect(hasInputSchemaChanged(beforeFlow, afterFlow)).toBe(true) + }) + + it('returns false for both empty schemas', () => { + const beforeFlow = { schema: {} } + const afterFlow = { schema: {} } + + expect(hasInputSchemaChanged(beforeFlow, afterFlow)).toBe(false) + }) + }) + + describe('multiple removed modules ordering', () => { + it('restores multiple removed modules in correct order', () => { + const moduleA = createRawScriptModule('a', 'first') + const moduleB = createRawScriptModule('b', 'second') + const moduleC = createRawScriptModule('c', 'third') + const moduleD = createRawScriptModule('d', 'fourth') + const moduleE = createRawScriptModule('e', 'fifth') + + // Remove modules at beginning, middle, and end + const beforeFlow = createFlow([moduleA, moduleB, moduleC, moduleD, moduleE]) + const afterFlow = createFlow([moduleB, moduleD]) // Remove a, c, e + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // All removed modules should be marked as removed + expect(result.beforeActions['a']).toEqual({ action: 'removed', pending: false }) + expect(result.beforeActions['c']).toEqual({ action: 'removed', pending: false }) + expect(result.beforeActions['e']).toEqual({ action: 'removed', pending: false }) + + // Verify mergedFlow restores all modules in the original order + expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'b', 'c', 'd', 'e']) + }) + + it('restores multiple removed modules inside a loop in correct order', () => { + const innerA = createRawScriptModule('inner_a', 'first') + const innerB = createRawScriptModule('inner_b', 'second') + const innerC = createRawScriptModule('inner_c', 'third') + const innerD = createRawScriptModule('inner_d', 'fourth') + + const beforeLoop = createForloopModule('loop1', [innerA, innerB, innerC, innerD]) + const afterLoop = createForloopModule('loop1', [innerB]) // Remove a, c, d + + const beforeFlow = createFlow([beforeLoop]) + const afterFlow = createFlow([afterLoop]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // All removed modules should be marked as removed + expect(result.beforeActions['inner_a']).toEqual({ action: 'removed', pending: false }) + expect(result.beforeActions['inner_c']).toEqual({ action: 'removed', pending: false }) + expect(result.beforeActions['inner_d']).toEqual({ action: 'removed', pending: false }) + + // Verify mergedFlow restores all modules in the original order inside the loop + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1') + const loopModules = (mergedLoop?.value as ForloopFlow).modules + expectModuleOrder(loopModules, ['inner_a', 'inner_b', 'inner_c', 'inner_d']) + }) + }) + + describe('insert position tests', () => { + it('handles module added at beginning of list', () => { + const moduleA = createRawScriptModule('a', 'existing first') + const moduleB = createRawScriptModule('b', 'existing second') + const moduleNew = createRawScriptModule('new', 'new at beginning') + + const beforeFlow = createFlow([moduleA, moduleB]) + const afterFlow = createFlow([moduleNew, moduleA, moduleB]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['new']).toEqual({ action: 'added', pending: false }) + + // Verify mergedFlow maintains correct order with new module at beginning + expectModuleOrder(result.mergedFlow.modules ?? [], ['new', 'a', 'b']) + }) + + it('handles module added in middle of list', () => { + const moduleA = createRawScriptModule('a', 'first') + const moduleB = createRawScriptModule('b', 'second') + const moduleC = createRawScriptModule('c', 'third') + const moduleNew = createRawScriptModule('new', 'new in middle') + + const beforeFlow = createFlow([moduleA, moduleB, moduleC]) + const afterFlow = createFlow([moduleA, moduleNew, moduleB, moduleC]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['new']).toEqual({ action: 'added', pending: false }) + + // Verify mergedFlow maintains correct order with new module in middle + expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'new', 'b', 'c']) + }) + + it('handles module added at beginning inside a loop', () => { + const innerA = createRawScriptModule('inner_a', 'existing first') + const innerB = createRawScriptModule('inner_b', 'existing second') + const innerNew = createRawScriptModule('inner_new', 'new at beginning') + + const beforeLoop = createForloopModule('loop1', [innerA, innerB]) + const afterLoop = createForloopModule('loop1', [innerNew, innerA, innerB]) + + const beforeFlow = createFlow([beforeLoop]) + const afterFlow = createFlow([afterLoop]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['inner_new']).toEqual({ action: 'added', pending: false }) + + // Verify correct order inside the loop + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1') + const loopModules = (mergedLoop?.value as ForloopFlow).modules + expectModuleOrder(loopModules, ['inner_new', 'inner_a', 'inner_b']) + }) + + it('handles multiple modules added at different positions', () => { + const moduleA = createRawScriptModule('a', 'original a') + const moduleB = createRawScriptModule('b', 'original b') + const moduleNew1 = createRawScriptModule('new1', 'new at start') + const moduleNew2 = createRawScriptModule('new2', 'new in middle') + const moduleNew3 = createRawScriptModule('new3', 'new at end') + + const beforeFlow = createFlow([moduleA, moduleB]) + const afterFlow = createFlow([moduleNew1, moduleA, moduleNew2, moduleB, moduleNew3]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['new1']).toEqual({ action: 'added', pending: false }) + expect(result.afterActions['new2']).toEqual({ action: 'added', pending: false }) + expect(result.afterActions['new3']).toEqual({ action: 'added', pending: false }) + + // Verify mergedFlow maintains correct order + expectModuleOrder(result.mergedFlow.modules ?? [], ['new1', 'a', 'new2', 'b', 'new3']) + }) + }) + + describe('empty containers', () => { + it('handles adding empty forloop', () => { + const moduleA = createRawScriptModule('a', 'step a') + const emptyLoop = createForloopModule('empty_loop', []) + + const beforeFlow = createFlow([moduleA]) + const afterFlow = createFlow([moduleA, emptyLoop]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['empty_loop']).toEqual({ action: 'added', pending: false }) + expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'empty_loop']) + + // Verify the empty loop has no modules + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'empty_loop') + expect((mergedLoop?.value as ForloopFlow).modules).toHaveLength(0) + }) + + it('handles removing empty forloop', () => { + const moduleA = createRawScriptModule('a', 'step a') + const emptyLoop = createForloopModule('empty_loop', []) + + const beforeFlow = createFlow([moduleA, emptyLoop]) + const afterFlow = createFlow([moduleA]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions['empty_loop']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['empty_loop']).toEqual({ action: 'removed', pending: false }) + + // Verify the removed empty loop is in mergedFlow + expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'empty_loop']) + }) + + it('handles adding empty branchone', () => { + const moduleA = createRawScriptModule('a', 'step a') + const emptyBranch = createBranchOneModule('empty_branch', [], []) + + const beforeFlow = createFlow([moduleA]) + const afterFlow = createFlow([moduleA, emptyBranch]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.afterActions['empty_branch']).toEqual({ action: 'added', pending: false }) + expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'empty_branch']) + + // Verify the empty branch has no modules in default or branches + const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'empty_branch') + expect((mergedBranch?.value as BranchOne).default).toHaveLength(0) + expect((mergedBranch?.value as BranchOne).branches).toHaveLength(0) + }) + + it('handles removing empty branchone with empty default and branches', () => { + const moduleA = createRawScriptModule('a', 'step a') + const emptyBranch = createBranchOneModule('empty_branch', [], [{ expr: 'true', modules: [] }]) + + const beforeFlow = createFlow([moduleA, emptyBranch]) + const afterFlow = createFlow([moduleA]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + expect(result.beforeActions['empty_branch']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['empty_branch']).toEqual({ action: 'removed', pending: false }) + + // Verify the removed empty branch is in mergedFlow with its structure + expectModuleOrder(result.mergedFlow.modules ?? [], ['a', 'empty_branch']) + const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'empty_branch') + expect((mergedBranch?.value as BranchOne).default).toHaveLength(0) + expect((mergedBranch?.value as BranchOne).branches).toHaveLength(1) + }) + }) + + describe('container type changes', () => { + it('treats forloop to whileloop change as removed + added', () => { + const innerModule = createRawScriptModule('inner', 'inner step') + + const forLoop = createForloopModule('loop1', [innerModule]) + const whileLoop = createWhileloopModule('loop1', [innerModule]) + + const beforeFlow = createFlow([forLoop]) + const afterFlow = createFlow([whileLoop]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // The container itself should be treated as removed + added (type changed) + expect(result.beforeActions['loop1']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['loop1']).toEqual({ action: 'added', pending: false }) + + // Inner module's location type changes (forloop -> whileloop), so it's also treated as moved + expect(result.beforeActions['inner']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['inner']).toEqual({ action: 'added', pending: false }) + + // The old inner module should appear with prefix in the merged flow + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1') + expect(mergedLoop).toBeDefined() + const loopModules = (mergedLoop?.value as WhileloopFlow).modules + // The whileloop contains 'inner' (new) and the old one gets prefixed + expect(loopModules.some((m) => m.id === 'inner')).toBe(true) + }) + + it('treats branchone to branchall change as removed + added', () => { + const innerModule = createRawScriptModule('inner', 'inner step') + + const branchOne = createBranchOneModule('branch1', [innerModule], []) + const branchAll = createBranchAllModule('branch1', [{ modules: [innerModule] }]) + + const beforeFlow = createFlow([branchOne]) + const afterFlow = createFlow([branchAll]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // The container itself should be treated as removed + added (type changed) + expect(result.beforeActions['branch1']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['branch1']).toEqual({ action: 'added', pending: false }) + + // Inner module's location type changes (branchone-default -> branchall-branch), so it's also treated as moved + expect(result.beforeActions['inner']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['inner']).toEqual({ action: 'added', pending: false }) + + // The new inner module should appear in the branchall + const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch1') + expect(mergedBranch).toBeDefined() + const branchModules = (mergedBranch?.value as BranchAll).branches[0].modules + expect(branchModules.some((m) => m.id === 'inner')).toBe(true) + }) + }) + + describe('module movement', () => { + it('detects module moved from root to inside a loop', () => { + const moduleA = createRawScriptModule('a', 'step a') + const moduleB = createRawScriptModule('b', 'step b') + const emptyLoop = createForloopModule('loop1', []) + const loopWithB = createForloopModule('loop1', [moduleB]) + + // Before: a, b, loop1(empty) + // After: a, loop1(b) + const beforeFlow = createFlow([moduleA, moduleB, emptyLoop]) + const afterFlow = createFlow([moduleA, loopWithB]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // Module b is in both flows but at different locations + // The implementation doesn't track "movement" - it sees b in both places + // The loop itself is modified because its modules changed + expect(result.beforeActions['loop1']).toEqual({ action: 'modified', pending: false }) + expect(result.afterActions['loop1']).toEqual({ action: 'modified', pending: false }) + + // b is removed from root and added inside loop - implementation sees it as existing in both + // but since it's the same module ID, no action is recorded + // The mergedFlow should contain both the old position (at root) and the new position (in loop) + // This is handled by the duplicate ID logic - old__b prefix + const rootModuleIds = result.mergedFlow.modules?.map((m) => m.id) ?? [] + expect(rootModuleIds).toContain('a') + expect(rootModuleIds).toContain('loop1') + // The old 'b' at root level gets prefixed with 'old__' + expect(rootModuleIds).toContain('old__b') + }) + + it('detects module moved from one branch to another', () => { + const moduleA = createRawScriptModule('a', 'step a') + + // Before: branch with 'a' in first conditional branch + const beforeBranch = createBranchOneModule('branch1', [], [ + { expr: 'x > 0', modules: [moduleA] }, + { expr: 'x < 0', modules: [] } + ]) + // After: branch with 'a' in second conditional branch + const afterBranch = createBranchOneModule('branch1', [], [ + { expr: 'x > 0', modules: [] }, + { expr: 'x < 0', modules: [moduleA] } + ]) + + const beforeFlow = createFlow([beforeBranch]) + const afterFlow = createFlow([afterBranch]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // The branch container is modified + expect(result.beforeActions['branch1']).toEqual({ action: 'modified', pending: false }) + expect(result.afterActions['branch1']).toEqual({ action: 'modified', pending: false }) + + // Module 'a' moved from branchIndex 0 to branchIndex 1, so it's treated as removed + added + expect(result.beforeActions['a']).toEqual({ action: 'removed', pending: false }) + expect(result.afterActions['a']).toEqual({ action: 'added', pending: false }) + + // The old 'a' should appear with prefix in the first branch of mergedFlow + const mergedBranch = result.mergedFlow.modules?.find((m) => m.id === 'branch1') + expect(mergedBranch).toBeDefined() + const firstBranchModules = (mergedBranch?.value as BranchOne).branches[0].modules + expect(firstBranchModules.some((m) => m.id === 'old__a')).toBe(true) + + // The new 'a' should appear in the second branch + const secondBranchModules = (mergedBranch?.value as BranchOne).branches[1].modules + expect(secondBranchModules.some((m) => m.id === 'a')).toBe(true) + }) + + it('detects module moved from loop to root', () => { + const moduleA = createRawScriptModule('a', 'step a') + const moduleB = createRawScriptModule('b', 'step b') + + const loopWithA = createForloopModule('loop1', [moduleA]) + const emptyLoop = createForloopModule('loop1', []) + + // Before: b, loop1(a) + // After: b, loop1(empty), a + const beforeFlow = createFlow([moduleB, loopWithA]) + const afterFlow = createFlow([moduleB, emptyLoop, moduleA]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // The loop is modified because its modules changed + expect(result.beforeActions['loop1']).toEqual({ action: 'modified', pending: false }) + expect(result.afterActions['loop1']).toEqual({ action: 'modified', pending: false }) + + // Module 'a' exists in both flows, so no action recorded for it + // The mergedFlow should contain the old 'a' inside the loop (prefixed) and new 'a' at root + const rootModuleIds = result.mergedFlow.modules?.map((m) => m.id) ?? [] + expect(rootModuleIds).toContain('b') + expect(rootModuleIds).toContain('loop1') + expect(rootModuleIds).toContain('a') + + // The old 'a' inside the loop gets prefixed + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1') + const loopModuleIds = (mergedLoop?.value as ForloopFlow).modules.map((m) => m.id) + expect(loopModuleIds).toContain('old__a') + }) + }) + + describe('module reordering', () => { + it('detects modules reordered at root level', () => { + const moduleA = createRawScriptModule('a', 'step a') + const moduleB = createRawScriptModule('b', 'step b') + const moduleC = createRawScriptModule('c', 'step c') + + // Before: a, b, c + // After: c, a, b + const beforeFlow = createFlow([moduleA, moduleB, moduleC]) + const afterFlow = createFlow([moduleC, moduleA, moduleB]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // No modules were added, removed, or modified - just reordered + // The implementation doesn't detect reordering as a change + expect(result.beforeActions['a']).toBeUndefined() + expect(result.beforeActions['b']).toBeUndefined() + expect(result.beforeActions['c']).toBeUndefined() + expect(result.afterActions['a']).toBeUndefined() + expect(result.afterActions['b']).toBeUndefined() + expect(result.afterActions['c']).toBeUndefined() + + // mergedFlow should reflect the afterFlow order + expectModuleOrder(result.mergedFlow.modules ?? [], ['c', 'a', 'b']) + }) + + it('detects modules reordered inside a loop', () => { + const innerA = createRawScriptModule('inner_a', 'inner a') + const innerB = createRawScriptModule('inner_b', 'inner b') + const innerC = createRawScriptModule('inner_c', 'inner c') + + // Before: loop with [a, b, c] + // After: loop with [c, b, a] + const beforeLoop = createForloopModule('loop1', [innerA, innerB, innerC]) + const afterLoop = createForloopModule('loop1', [innerC, innerB, innerA]) + + const beforeFlow = createFlow([beforeLoop]) + const afterFlow = createFlow([afterLoop]) + + const result = buildFlowTimeline(beforeFlow, afterFlow) + + // The loop is modified because its internal structure changed + // (deepEqual compares the modules array which includes order) + expect(result.beforeActions['loop1']).toEqual({ action: 'modified', pending: false }) + expect(result.afterActions['loop1']).toEqual({ action: 'modified', pending: false }) + + // Individual modules are not marked as changed + expect(result.beforeActions['inner_a']).toBeUndefined() + expect(result.beforeActions['inner_b']).toBeUndefined() + expect(result.beforeActions['inner_c']).toBeUndefined() + + // mergedFlow should reflect the afterFlow order inside the loop + const mergedLoop = result.mergedFlow.modules?.find((m) => m.id === 'loop1') + const loopModules = (mergedLoop?.value as ForloopFlow).modules + expectModuleOrder(loopModules, ['inner_c', 'inner_b', 'inner_a']) + }) + }) +}) diff --git a/frontend/src/lib/components/flows/flowDiff.testUtils.ts b/frontend/src/lib/components/flows/flowDiff.testUtils.ts new file mode 100644 index 0000000000..a72a5d19b2 --- /dev/null +++ b/frontend/src/lib/components/flows/flowDiff.testUtils.ts @@ -0,0 +1,166 @@ +import { expect } from 'vitest' +import type { + FlowValue, + FlowModule, + RawScript, + Identity, + ForloopFlow, + WhileloopFlow, + BranchOne, + BranchAll +} from '$lib/gen' +import type { ExtendedOpenFlow } from './types' +import type { StateStore } from '$lib/utils' + +// ============================================================================ +// Module Creation Helpers +// ============================================================================ + +/** + * Creates a minimal RawScript module for testing + */ +export function createRawScriptModule(id: string, content: string): FlowModule { + return { + id, + value: { + type: 'rawscript', + content, + language: 'bun', + input_transforms: {} + } as RawScript + } +} + +/** + * Creates a minimal Identity module (useful for type-change tests) + */ +export function createIdentityModule(id: string): FlowModule { + return { + id, + value: { + type: 'identity' + } as Identity + } +} + +/** + * Creates a ForloopFlow module with nested modules + */ +export function createForloopModule(id: string, nestedModules: FlowModule[]): FlowModule { + return { + id, + value: { + type: 'forloopflow', + modules: nestedModules, + iterator: { type: 'javascript', expr: '[1,2,3]' }, + skip_failures: false + } as ForloopFlow + } +} + +/** + * Creates a WhileloopFlow module with nested modules + */ +export function createWhileloopModule(id: string, nestedModules: FlowModule[]): FlowModule { + return { + id, + value: { + type: 'whileloopflow', + modules: nestedModules, + skip_failures: false + } as WhileloopFlow + } +} + +/** + * Creates a BranchOne module with default and conditional branches + */ +export function createBranchOneModule( + id: string, + defaultModules: FlowModule[], + branches: { expr: string; modules: FlowModule[] }[] +): FlowModule { + return { + id, + value: { + type: 'branchone', + default: defaultModules, + branches: branches.map((b) => ({ expr: b.expr, modules: b.modules })) + } as BranchOne + } +} + +/** + * Creates a BranchAll module with parallel branches + */ +export function createBranchAllModule(id: string, branches: { modules: FlowModule[] }[]): FlowModule { + return { + id, + value: { + type: 'branchall', + branches: branches.map((b) => ({ modules: b.modules })) + } as BranchAll + } +} + +// ============================================================================ +// Flow Creation Helpers +// ============================================================================ + +/** + * Creates a FlowValue with the given modules + */ +export function createFlow(modules: FlowModule[]): FlowValue { + return { modules } +} + +/** + * Creates a FlowValue with optional special modules (failure_module, preprocessor_module) + */ +export function createFlowWithSpecialModules(options: { + modules?: FlowModule[] + failure_module?: FlowModule + preprocessor_module?: FlowModule +}): FlowValue { + return { + modules: options.modules ?? [], + ...(options.failure_module && { failure_module: options.failure_module }), + ...(options.preprocessor_module && { preprocessor_module: options.preprocessor_module }) + } +} + +/** + * Wraps a FlowValue in an ExtendedOpenFlow for manager tests + */ +export function createExtendedOpenFlow( + flowValue: FlowValue, + schema?: Record +): ExtendedOpenFlow { + return { value: flowValue, summary: '', schema } +} + +/** + * Creates a minimal StateStore for manager tests + */ +export function createFlowStore(flow: ExtendedOpenFlow): StateStore { + return { val: flow } +} + +// ============================================================================ +// Utility Helpers +// ============================================================================ + +/** + * Deep clones an object via JSON serialization + */ +export function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)) +} + +/** + * Asserts that modules appear in the exact order specified. + * Also implicitly verifies the count of modules. + */ +export function expectModuleOrder(modules: FlowModule[], expectedIds: string[]): void { + expect(modules.map((m) => m.id)).toEqual(expectedIds) +} diff --git a/frontend/src/lib/components/flows/flowDiff.ts b/frontend/src/lib/components/flows/flowDiff.ts index d77d25af42..3daa35122a 100644 --- a/frontend/src/lib/components/flows/flowDiff.ts +++ b/frontend/src/lib/components/flows/flowDiff.ts @@ -1,17 +1,59 @@ import type { FlowModule, FlowValue } from '$lib/gen' import { dfs } from './dfs' import { deepEqual } from 'fast-equals' -import type { AIModuleAction } from '../copilot/chat/flow/core' + +/** Prefix added to module IDs when the original module coexists with a replacement */ +export const DUPLICATE_MODULE_PREFIX = 'old__' + +/** Prefix added to new module IDs when restoring original during type change rejection */ +export const NEW_MODULE_PREFIX = 'new__' + +/** + * Action types for flow module changes during diff tracking + * - added: Module was added to the flow + * - modified: Module content was changed + * - removed: Module was deleted from the flow + * - shadowed: Module is shown as removed (visualization mode) + */ +export type AIModuleAction = 'added' | 'modified' | 'removed' | 'shadowed' | undefined + +/** + * Tracks the action performed on a module and whether it requires user approval + */ +export type ModuleActionInfo = { + action: AIModuleAction + /** Whether this change is pending user approval (accept/reject) */ + pending: boolean +} + +/** + * Normalizes a FlowModule for comparison by removing properties that + * should be ignored when determining if a module has changed. + * Specifically, removes empty `assets` arrays since their presence/absence + * is not a meaningful difference. + */ +function normalizeModuleForComparison(module: FlowModule): FlowModule { + const normalized = { ...module } + if ('value' in normalized && normalized.value && typeof normalized.value === 'object') { + const value = { ...normalized.value } as Record + // Remove empty assets array - it's not a meaningful difference + if (Array.isArray(value.assets) && value.assets.length === 0) { + delete value.assets + } + normalized.value = value as FlowModule['value'] + } + return normalized +} /** * The complete diff result with action maps and merged flow */ export type FlowTimeline = { /** Actions for modules in the before flow */ - beforeActions: Record + beforeActions: Record /** Actions for modules in the after flow (adjusted based on display mode) */ - afterActions: Record + afterActions: Record /** The merged flow containing both after modules and removed modules properly nested */ mergedFlow: FlowValue @@ -29,40 +71,56 @@ export type FlowTimeline = { */ export function computeFlowModuleDiff( beforeFlow: FlowValue, - afterFlow: FlowValue -): { beforeActions: Record; afterActions: Record } { - const beforeActions: Record = {} - const afterActions: Record = {} + afterFlow: FlowValue, + options: { markAsPending: boolean } = { markAsPending: false } +): { + beforeActions: Record + afterActions: Record +} { + const beforeActions: Record = {} + const afterActions: Record = {} - // Get all modules from both flows using dfs - const beforeModules = getAllModulesMap(beforeFlow) - const afterModules = getAllModulesMap(afterFlow) + // Get all modules with their locations from both flows + const beforeModulesWithLoc = getAllModulesWithLocation(beforeFlow) + const afterModulesWithLoc = getAllModulesWithLocation(afterFlow) // Find all module IDs - const allModuleIds = new Set([...beforeModules.keys(), ...afterModules.keys()]) + const allModuleIds = new Set([...beforeModulesWithLoc.keys(), ...afterModulesWithLoc.keys()]) for (const moduleId of allModuleIds) { - const beforeModule = beforeModules.get(moduleId) - const afterModule = afterModules.get(moduleId) + const beforeEntry = beforeModulesWithLoc.get(moduleId) + const afterEntry = afterModulesWithLoc.get(moduleId) - if (!beforeModule && afterModule) { + if (!beforeEntry && afterEntry) { // Module exists in after but not before -> added - afterActions[moduleId] = 'added' - } else if (beforeModule && !afterModule) { + afterActions[moduleId] = { action: 'added', pending: options.markAsPending } + } else if (beforeEntry && !afterEntry) { // Module exists in before but not after -> removed - beforeActions[moduleId] = 'removed' - afterActions[moduleId] = 'shadowed' - } else if (beforeModule && afterModule) { - // Module exists in both -> check type and content - const typeChanged = beforeModule.value.type !== afterModule.value.type - if (typeChanged) { - // Type changed -> treat as removed + added - beforeActions[moduleId] = 'removed' - afterActions[moduleId] = 'added' - } else if (!deepEqual(beforeModule, afterModule)) { - // Same type but different content -> modified - beforeActions[moduleId] = 'modified' - afterActions[moduleId] = 'modified' + beforeActions[moduleId] = { action: 'removed', pending: options.markAsPending } + afterActions[moduleId] = { action: 'shadowed', pending: options.markAsPending } + } else if (beforeEntry && afterEntry) { + // Module exists in both -> check location first, then type and content + if (!locationsEqual(beforeEntry.location, afterEntry.location)) { + // Location changed -> treat as removed from old + added at new + beforeActions[moduleId] = { action: 'removed', pending: options.markAsPending } + afterActions[moduleId] = { action: 'added', pending: options.markAsPending } + } else { + // Same location -> check type and content + const typeChanged = beforeEntry.module.value.type !== afterEntry.module.value.type + if (typeChanged) { + // Type changed -> treat as removed + added + beforeActions[moduleId] = { action: 'removed', pending: options.markAsPending } + afterActions[moduleId] = { action: 'added', pending: options.markAsPending } + } else if ( + !deepEqual( + normalizeModuleForComparison(beforeEntry.module), + normalizeModuleForComparison(afterEntry.module) + ) + ) { + // Same type but different content -> modified + beforeActions[moduleId] = { action: 'modified', pending: options.markAsPending } + afterActions[moduleId] = { action: 'modified', pending: options.markAsPending } + } } } } @@ -97,10 +155,81 @@ function getAllModulesMap(flow: FlowValue): Map { return moduleMap } +/** + * Represents a module along with its location in the flow + */ +type ModuleWithLocation = { + module: FlowModule + location: ModuleParentLocation +} + +/** + * Helper function to get all modules from a flow as a Map with their locations + */ +function getAllModulesWithLocation(flow: FlowValue): Map { + const result = new Map() + const allModules = dfs(flow.modules ?? [], (m) => m) + + for (const module of allModules) { + if (module?.id) { + const location = findModuleParent(flow, module.id) + if (location) { + result.set(module.id, { module, location }) + } + } + } + + // Add special modules + if (flow.failure_module?.id) { + result.set(flow.failure_module.id, { + module: flow.failure_module, + location: { type: 'failure', index: -1 } + }) + } + if (flow.preprocessor_module?.id) { + result.set(flow.preprocessor_module.id, { + module: flow.preprocessor_module, + location: { type: 'preprocessor', index: -1 } + }) + } + + return result +} + +/** + * Compares two module locations for equality. + * Two locations are equal if they refer to the same parent container. + * Index within the container is not considered (modules can be reordered). + */ +export function locationsEqual(a: ModuleParentLocation | null, b: ModuleParentLocation | null): boolean { + if (!a || !b) return a === b + if (a.type !== b.type) return false + + switch (a.type) { + case 'root': + case 'failure': + case 'preprocessor': + return true // Same type is enough (index doesn't matter for location equality) + case 'forloop': + case 'whileloop': + case 'aiagent': + return a.parentId === (b as typeof a).parentId + case 'branchone-default': + return a.parentId === (b as typeof a).parentId + case 'branchone-branch': + case 'branchall-branch': + return ( + a.parentId === (b as typeof a).parentId && a.branchIndex === (b as typeof a).branchIndex + ) + default: + return false + } +} + /** * Represents the parent location of a module */ -type ModuleParentLocation = +export type ModuleParentLocation = | { type: 'root'; index: number } | { type: 'forloop' | 'whileloop'; parentId: string; index: number } | { type: 'branchone-default'; parentId: string; index: number } @@ -113,7 +242,7 @@ type ModuleParentLocation = /** * Finds the parent location of a module in a flow */ -function findModuleParent(flow: FlowValue, moduleId: string): ModuleParentLocation | null { +export function findModuleParent(flow: FlowValue, moduleId: string): ModuleParentLocation | null { // Check special modules if (flow.failure_module?.id === moduleId) { return { type: 'failure', index: -1 } @@ -210,11 +339,62 @@ function cloneModule(module: FlowModule): FlowModule { } /** - * Prepends a prefix to a module's ID to avoid collisions + * Prepends a prefix to a module's ID and all nested child module IDs to avoid collisions */ function prependModuleId(module: FlowModule, prefix: string): FlowModule { const newModule = cloneModule(module) newModule.id = prefix + newModule.id + + // Recursively prefix nested module IDs + if (newModule.value.type === 'forloopflow' || newModule.value.type === 'whileloopflow') { + newModule.value.modules = newModule.value.modules.map((m) => prependModuleId(m, prefix)) + } else if (newModule.value.type === 'branchone') { + newModule.value.default = newModule.value.default.map((m) => prependModuleId(m, prefix)) + newModule.value.branches = newModule.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules.map((m) => prependModuleId(m, prefix)) + })) + } else if (newModule.value.type === 'branchall') { + newModule.value.branches = newModule.value.branches.map((branch) => ({ + ...branch, + modules: branch.modules.map((m) => prependModuleId(m, prefix)) + })) + } else if (newModule.value.type === 'aiagent' && newModule.value.tools) { + // Handle aiagent tools - only prefix FlowModule tools, not MCP tools + newModule.value.tools = newModule.value.tools.map((tool) => { + // MCP tools have tool_type: 'mcp', FlowModule tools have tool_type: 'flowmodule' or undefined + if (tool.value.tool_type === 'mcp') { + return tool // MCP tools don't have nested module IDs + } + // For FlowModule tools, prefix the ID and recurse + const prefixedTool = { + ...tool, + id: prefix + tool.id + } + // If the tool has nested modules (it's a container type), recurse + const innerValue = tool.value as FlowModule['value'] + if (innerValue.type === 'forloopflow' || innerValue.type === 'whileloopflow') { + ;(prefixedTool.value as any).modules = (innerValue as any).modules.map((m: FlowModule) => + prependModuleId(m, prefix) + ) + } else if (innerValue.type === 'branchone') { + ;(prefixedTool.value as any).default = (innerValue as any).default.map((m: FlowModule) => + prependModuleId(m, prefix) + ) + ;(prefixedTool.value as any).branches = (innerValue as any).branches.map((branch: any) => ({ + ...branch, + modules: branch.modules.map((m: FlowModule) => prependModuleId(m, prefix)) + })) + } else if (innerValue.type === 'branchall') { + ;(prefixedTool.value as any).branches = (innerValue as any).branches.map((branch: any) => ({ + ...branch, + modules: branch.modules.map((m: FlowModule) => prependModuleId(m, prefix)) + })) + } + return prefixedTool + }) + } + return newModule } @@ -264,28 +444,122 @@ function getAllModuleIds(flow: FlowValue): Set { return ids } +/** + * Scans the merged flow for duplicate IDs and prefixes duplicates with 'old__'. + * This handles the case where a module is moved from one location to another - + * both the old and new versions end up in the merged flow with the same ID. + */ +function fixDuplicateIds(merged: FlowValue, beforeFlow: FlowValue): void { + const seenIds = new Set() + const beforeModulesMap = getAllModulesMap(beforeFlow) + + // Process a single module - returns the (possibly prefixed) module + function processModule(module: FlowModule): FlowModule { + let result = module + + if (seenIds.has(module.id)) { + // Duplicate found! Check if this one exists in beforeFlow + const beforeModule = beforeModulesMap.get(module.id) + if (beforeModule) { + // This is the "old" version - prefix it and all its children + result = prependModuleId(module, DUPLICATE_MODULE_PREFIX) + } + } else { + seenIds.add(module.id) + } + + // Recurse into nested modules (use result which may be prefixed) + processNestedModules(result) + + return result + } + + // Process nested modules in-place + function processNestedModules(module: FlowModule): void { + if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') { + module.value.modules = module.value.modules.map((m) => processModule(m)) + } else if (module.value.type === 'branchone') { + module.value.default = module.value.default.map((m) => processModule(m)) + for (const branch of module.value.branches) { + branch.modules = branch.modules.map((m) => processModule(m)) + } + } else if (module.value.type === 'branchall') { + for (const branch of module.value.branches) { + branch.modules = branch.modules.map((m) => processModule(m)) + } + } else if (module.value.type === 'aiagent' && module.value.tools) { + // For aiagent tools, we need to track IDs of FlowModule tools + for (const tool of module.value.tools) { + if (tool.value.tool_type !== 'mcp') { + if (seenIds.has(tool.id)) { + // Can't easily prefix in-place here, but aiagent tools rarely move + // The main use case is regular modules moving in/out of loops/branches + } else { + seenIds.add(tool.id) + } + } + } + } + } + + // Process root modules + if (merged.modules) { + merged.modules = merged.modules.map((m) => processModule(m)) + } + + // Process special modules + if (merged.failure_module) { + if (seenIds.has(merged.failure_module.id)) { + const beforeModule = beforeModulesMap.get(merged.failure_module.id) + if (beforeModule) { + merged.failure_module = prependModuleId(merged.failure_module, DUPLICATE_MODULE_PREFIX) + } + } else { + seenIds.add(merged.failure_module.id) + } + } + + if (merged.preprocessor_module) { + if (seenIds.has(merged.preprocessor_module.id)) { + const beforeModule = beforeModulesMap.get(merged.preprocessor_module.id) + if (beforeModule) { + merged.preprocessor_module = prependModuleId( + merged.preprocessor_module, + DUPLICATE_MODULE_PREFIX + ) + } + } else { + seenIds.add(merged.preprocessor_module.id) + } + } +} + /** * Reconstructs the merged flow with removed modules properly nested */ function reconstructMergedFlow( afterFlow: FlowValue, beforeFlow: FlowValue, - beforeActions: Record + beforeActions: Record ): FlowValue { // Deep clone afterFlow to avoid mutation const merged: FlowValue = JSON.parse(JSON.stringify(afterFlow)) // Get all removed/shadowed modules from beforeFlow const removedModules = Object.entries(beforeActions) - .filter(([_, action]) => action === 'removed' || action === 'shadowed') + .filter(([_, action]) => action.action === 'removed' || action.action === 'shadowed') .map(([id]) => id) // Create a Set for faster lookup const removedModulesSet = new Set(removedModules) + // Cache beforeFlow modules map and merged IDs to avoid recomputing in the loop + const beforeModulesMap = getAllModulesMap(beforeFlow) + const mergedIds = getAllModuleIds(merged) + // For each removed module, find its parent and insert it for (const removedId of removedModules) { - const beforeModule = getAllModulesMap(beforeFlow).get(removedId) + const beforeModule = beforeModulesMap.get(removedId) if (!beforeModule) continue const parentLocation = findModuleParent(beforeFlow, removedId) @@ -308,12 +582,14 @@ function reconstructMergedFlow( // Check for ID collision - this happens when a module type changed // In this case, the new module is already in the merged flow as 'added' - // We need to prepend "__" to the removed module's ID so both can coexist - const existingIds = getAllModuleIds(merged) - if (existingIds.has(clonedModule.id)) { - clonedModule = prependModuleId(clonedModule, '__') + // We prepend the duplicate prefix to the removed module's ID so both can coexist + if (mergedIds.has(clonedModule.id)) { + clonedModule = prependModuleId(clonedModule, DUPLICATE_MODULE_PREFIX) } + // Track the newly added module ID + mergedIds.add(clonedModule.id) + // Insert based on parent location if (parentLocation.type === 'failure') { merged.failure_module = clonedModule @@ -335,6 +611,10 @@ function reconstructMergedFlow( } } + // Post-process: fix any duplicate IDs that may have been created + // This handles the case where a module moved from one location to another + fixDuplicateIds(merged, beforeFlow) + return merged } @@ -389,11 +669,17 @@ function insertIntoNestedParent( // Find the parent module in merged flow const parentModule = findModuleById(merged, parentLocation.parentId) - if (!parentModule) return + if (!parentModule) { + console.warn('Parent module not found', parentLocation) + return + } // Get the before parent to know original ordering const beforeParent = findModuleById(beforeFlow, parentLocation.parentId) - if (!beforeParent) return + if (!beforeParent) { + console.warn('Before parent module not found', parentLocation) + return + } // Insert based on type if (parentLocation.type === 'forloop' && parentModule.value.type === 'forloopflow') { @@ -430,9 +716,24 @@ function insertIntoNestedParent( parentLocation.type === 'branchone-branch' && parentModule.value.type === 'branchone' ) { - const branch = parentModule.value.branches[parentLocation.branchIndex] + let branch = parentModule.value.branches[parentLocation.branchIndex] + const beforeBranch = (beforeParent.value as any).branches?.[parentLocation.branchIndex] + + // If the branch doesn't exist (entire branch was removed), recreate it from beforeFlow + if (!branch && beforeBranch) { + // Ensure we have enough branch slots + while (parentModule.value.branches.length <= parentLocation.branchIndex) { + parentModule.value.branches.push({ expr: '', modules: [] }) + } + // Restore the branch with its original expr but empty modules (we'll add them) + parentModule.value.branches[parentLocation.branchIndex] = { + ...beforeBranch, + modules: [] + } + branch = parentModule.value.branches[parentLocation.branchIndex] + } + if (branch) { - const beforeBranch = (beforeParent.value as any).branches?.[parentLocation.branchIndex] const beforeModules = beforeBranch?.modules ?? [] const insertIndex = findBestInsertPosition( branch.modules, @@ -446,9 +747,24 @@ function insertIntoNestedParent( parentLocation.type === 'branchall-branch' && parentModule.value.type === 'branchall' ) { - const branch = parentModule.value.branches[parentLocation.branchIndex] + let branch = parentModule.value.branches[parentLocation.branchIndex] + const beforeBranch = (beforeParent.value as any).branches?.[parentLocation.branchIndex] + + // If the branch doesn't exist (entire branch was removed), recreate it from beforeFlow + if (!branch && beforeBranch) { + // Ensure we have enough branch slots + while (parentModule.value.branches.length <= parentLocation.branchIndex) { + parentModule.value.branches.push({ modules: [] }) + } + // Restore the branch with empty modules (we'll add them) + parentModule.value.branches[parentLocation.branchIndex] = { + ...beforeBranch, + modules: [] + } + branch = parentModule.value.branches[parentLocation.branchIndex] + } + if (branch) { - const beforeBranch = (beforeParent.value as any).branches?.[parentLocation.branchIndex] const beforeModules = beforeBranch?.modules ?? [] const insertIndex = findBestInsertPosition( branch.modules, @@ -483,33 +799,36 @@ function findModuleById(flow: FlowValue, moduleId: string): FlowModule | null { * Adjusts the after actions based on display mode and adds entries for prefixed IDs */ function adjustActionsForDisplay( - afterActions: Record, - beforeActions: Record, + afterActions: Record, + beforeActions: Record, markRemovedAsShadowed: boolean, mergedFlow: FlowValue -): Record { - const adjusted: Record = {} +): Record { + const adjusted: Record = {} // Copy all existing actions for (const [id, action] of Object.entries(afterActions)) { - if (!markRemovedAsShadowed && action === 'shadowed') { + if (!markRemovedAsShadowed && action.action === 'shadowed') { // In unified mode, change 'shadowed' to 'removed' for proper coloring - adjusted[id] = 'removed' + adjusted[id] = { action: 'removed', pending: action.pending } } else { adjusted[id] = action } } // Add entries for prefixed IDs (modules that had type changes or were removed) - // These are the old versions that got "__" prepended to their ID + // These are the old versions that got the duplicate prefix prepended to their ID const allMergedIds = getAllModuleIds(mergedFlow) for (const id of allMergedIds) { - if (id.startsWith('__') && !adjusted[id]) { + if (id.startsWith(DUPLICATE_MODULE_PREFIX) && !adjusted[id]) { // This is a prefixed ID for a module that was removed - const originalId = id.substring(2) + const originalId = id.substring(DUPLICATE_MODULE_PREFIX.length) // Check beforeActions to see if this module was removed - if (beforeActions[originalId] === 'removed') { - adjusted[id] = markRemovedAsShadowed ? 'shadowed' : 'removed' + if (beforeActions[originalId]?.action === 'removed') { + adjusted[id] = { + action: markRemovedAsShadowed ? 'shadowed' : 'removed', + pending: beforeActions[originalId].pending + } } } } @@ -530,10 +849,15 @@ function adjustActionsForDisplay( export function buildFlowTimeline( beforeFlow: FlowValue, afterFlow: FlowValue, - options: { markRemovedAsShadowed: boolean } = { markRemovedAsShadowed: false } + options: { markRemovedAsShadowed: boolean; markAsPending: boolean } = { + markRemovedAsShadowed: false, + markAsPending: false + } ): FlowTimeline { // Compute the diff between the two flows - const { beforeActions, afterActions } = computeFlowModuleDiff(beforeFlow, afterFlow) + const { beforeActions, afterActions } = computeFlowModuleDiff(beforeFlow, afterFlow, { + markAsPending: options.markAsPending + }) // Reconstruct merged flow with removed modules properly nested const mergedFlow = reconstructMergedFlow(afterFlow, beforeFlow, beforeActions) @@ -553,6 +877,62 @@ export function buildFlowTimeline( } } +/** + * Inserts a module into a flow at its correct position based on where it was located in the source flow. + * This is useful when restoring a removed module - it finds the correct parent and position. + * + * @param targetFlow - The flow to insert the module into + * @param moduleToInsert - The module to insert + * @param sourceFlow - The flow where the module originally existed (to find parent location and ordering) + * @param moduleId - The ID of the module being inserted + */ +export function insertModuleIntoFlow( + targetFlow: FlowValue, + moduleToInsert: FlowModule, + sourceFlow: FlowValue, + moduleId: string +): void { + const parentLocation = findModuleParent(sourceFlow, moduleId) + if (!parentLocation) return + + // Handle special modules + if (parentLocation.type === 'failure') { + targetFlow.failure_module = moduleToInsert + return + } + if (parentLocation.type === 'preprocessor') { + targetFlow.preprocessor_module = moduleToInsert + return + } + + // Handle root level modules + if (parentLocation.type === 'root') { + const insertIndex = findBestInsertPosition( + targetFlow.modules ?? [], + sourceFlow.modules ?? [], + parentLocation.index, + moduleId + ) + if (!targetFlow.modules) targetFlow.modules = [] + targetFlow.modules.splice(insertIndex, 0, moduleToInsert) + return + } + + // Handle nested modules + insertIntoNestedParent(targetFlow, parentLocation, moduleToInsert, sourceFlow) +} + +/** + * Finds a module by ID anywhere in a flow (including nested modules, failure, and preprocessor) + * + * @param flow - The flow to search in + * @param moduleId - The ID of the module to find + * @returns The module if found, null otherwise + */ +export function findModuleInFlow(flow: FlowValue, moduleId: string): FlowModule | null { + return findModuleById(flow, moduleId) +} + /** * Checks if the input schema has changed between two flow versions. * The input schema always exists (even if empty), so we only check for modifications. diff --git a/frontend/src/lib/components/flows/flowDiffManager.svelte.test.ts b/frontend/src/lib/components/flows/flowDiffManager.svelte.test.ts new file mode 100644 index 0000000000..a008ddf6d3 --- /dev/null +++ b/frontend/src/lib/components/flows/flowDiffManager.svelte.test.ts @@ -0,0 +1,1619 @@ +import { describe, it, expect } from 'vitest' +import { flushSync } from 'svelte' +import { createFlowDiffManager } from './flowDiffManager.svelte' +import { DUPLICATE_MODULE_PREFIX, NEW_MODULE_PREFIX } from './flowDiff' +import type { FlowValue, RawScript, ForloopFlow, BranchOne, BranchAll } from '$lib/gen' +import { SPECIAL_MODULE_IDS } from '../copilot/chat/shared' +import { + createRawScriptModule, + createForloopModule, + createBranchOneModule, + createBranchAllModule, + createExtendedOpenFlow, + createFlowStore, + deepClone, + expectModuleOrder, + createIdentityModule +} from './flowDiff.testUtils' + +describe('FlowDiffManager', () => { + describe('effect auto-computation', () => { + it('auto-computes moduleActions when flows differ', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'before') + const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) + + const moduleB = createRawScriptModule('b', 'new') + const afterFlow: FlowValue = { modules: [moduleA, moduleB] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + + flushSync() + + expect(manager.moduleActions).toHaveProperty('b') + expect(manager.moduleActions['b']).toEqual({ action: 'added', pending: true }) + }) + cleanup() + }) + }) + + describe('acceptModule', () => { + it('accepts added module - inserts into beforeFlow', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) + const afterFlow: FlowValue = { modules: [moduleA, moduleB] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + // Verify 'b' is detected as added + expect(manager.moduleActions['b']).toEqual({ action: 'added', pending: true }) + + // Accept the added module + manager.acceptModule('b') + flushSync() + + // After accepting, beforeFlow should now contain module 'b' + const beforeModules = manager.beforeFlow?.value.modules ?? [] + expect(beforeModules.map((m) => m.id)).toContain('b') + // The action should be cleared since beforeFlow now matches currentFlow + expect(manager.moduleActions['b']).toBeUndefined() + }) + cleanup() + }) + + it('accepts removed module - deletes from beforeFlow', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const beforeFlow = createExtendedOpenFlow({ modules: [moduleA, moduleB] }) + const afterFlow: FlowValue = { modules: [moduleA] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + // Verify 'b' is detected as removed + expect(manager.moduleActions['b']).toEqual({ action: 'removed', pending: true }) + + // Accept the removal + manager.acceptModule('b') + flushSync() + + // After accepting, beforeFlow should no longer contain module 'b' + const beforeModules = manager.beforeFlow?.value.modules ?? [] + expect(beforeModules.map((m) => m.id)).not.toContain('b') + expect(manager.moduleActions['b']).toBeUndefined() + }) + cleanup() + }) + + it('accepts modified module - updates beforeFlow content', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleBeforeA = createRawScriptModule('a', 'original-content') + const moduleAfterA = createRawScriptModule('a', 'modified-content') + const beforeFlow = createExtendedOpenFlow({ modules: [moduleBeforeA] }) + const afterFlow: FlowValue = { modules: [moduleAfterA] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + // Verify 'a' is detected as modified + expect(manager.moduleActions['a']).toEqual({ action: 'modified', pending: true }) + + // Accept the modification + manager.acceptModule('a') + flushSync() + + // After accepting, beforeFlow module should have the new content + const beforeModules = manager.beforeFlow?.value.modules ?? [] + const moduleA = beforeModules.find((m) => m.id === 'a') + expect((moduleA?.value as RawScript).content).toBe('modified-content') + expect(manager.moduleActions['a']).toBeUndefined() + }) + cleanup() + }) + + it('accepts added nested module - parent accepted as skeleton first', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + // beforeFlow: empty + const beforeFlow = createExtendedOpenFlow({ modules: [] }) + + // afterFlow: forloop containing a nested module + const nestedModule = createRawScriptModule('nested', 'nested-content') + const forloop = createForloopModule('loop', [nestedModule]) + const afterFlow: FlowValue = { modules: [forloop] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + // Both 'loop' and 'nested' should be detected as added + expect(manager.moduleActions['loop']).toEqual({ action: 'added', pending: true }) + expect(manager.moduleActions['nested']).toEqual({ action: 'added', pending: true }) + + // Accept the nested module first - should auto-accept parent as skeleton + manager.acceptModule('nested') + flushSync() + + // After accepting nested, the loop should also be in beforeFlow (as skeleton initially) + const beforeModules = manager.beforeFlow?.value.modules ?? [] + expect(beforeModules.map((m) => m.id)).toContain('loop') + + // The nested module should be in the loop + const loopModule = beforeModules.find((m) => m.id === 'loop') + const loopValue = loopModule?.value as ForloopFlow + expect(loopValue.modules.map((m) => m.id)).toContain('nested') + }) + cleanup() + }) + + it('accepts input schema changes', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const beforeSchema = { + properties: { x: { type: 'string' } } + } + const afterSchema = { + properties: { x: { type: 'string' }, y: { type: 'number' } } + } + const beforeFlow = createExtendedOpenFlow({ modules: [] }, beforeSchema) + const afterFlow: FlowValue = { modules: [] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + manager.setCurrentInputSchema(afterSchema) + flushSync() + + // Input schema change should be detected + expect(manager.moduleActions[SPECIAL_MODULE_IDS.INPUT]).toEqual({ + action: 'modified', + pending: true + }) + + // Accept the input schema change + manager.acceptModule(SPECIAL_MODULE_IDS.INPUT) + flushSync() + + // beforeFlow schema should now match currentInputSchema + expect(manager.beforeFlow?.schema).toEqual(afterSchema) + }) + cleanup() + }) + + it('handles duplicate ID prefix (old__) for type changes', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + // Create a real type change: rawscript -> identity + const moduleBeforeA = createRawScriptModule('a', 'content') + const moduleAfterA = createIdentityModule('a') + const beforeFlow = createExtendedOpenFlow({ modules: [moduleBeforeA] }) + const afterFlow: FlowValue = { modules: [moduleAfterA] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + // Type change produces: 'a' (added) and 'old__a' (removed) + expect(manager.moduleActions['a']).toEqual({ action: 'added', pending: true }) + expect(manager.moduleActions[`${DUPLICATE_MODULE_PREFIX}a`]).toEqual({ action: 'removed', pending: true }) + + // Accept the removal (old__a) - should remove original module from beforeFlow + manager.acceptModule(`${DUPLICATE_MODULE_PREFIX}a`) + flushSync() + + // The module 'a' (original rawscript) should be removed from beforeFlow + const beforeModules = manager.beforeFlow?.value.modules ?? [] + expect(beforeModules.map((m) => m.id)).not.toContain('a') + }) + cleanup() + }) + }) + + describe('rejectModule', () => { + it('rejects added module - removes from flowStore', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA)] }) + + const currentFlowValue: FlowValue = { + modules: [deepClone(moduleA), deepClone(moduleB)] + } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + expect(manager.moduleActions['b']).toEqual({ action: 'added', pending: true }) + + // Reject the added module + manager.rejectModule('b', flowStore) + flushSync() + + // After rejecting, flowStore should no longer contain module 'b' + const currentModules = flowStore.val.value.modules + expect(currentModules.map((m) => m.id)).not.toContain('b') + expect(currentModules.map((m) => m.id)).toContain('a') + }) + cleanup() + }) + + it('rejects removed module - restores to flowStore', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const beforeFlow = createExtendedOpenFlow({ + modules: [deepClone(moduleA), deepClone(moduleB)] + }) + + const currentFlowValue: FlowValue = { modules: [deepClone(moduleA)] } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + expect(manager.moduleActions['b']).toEqual({ action: 'removed', pending: true }) + + // Reject the removal - should restore module 'b' + manager.rejectModule('b', flowStore) + flushSync() + + // After rejecting, flowStore should contain module 'b' again + const currentModules = flowStore.val.value.modules + expect(currentModules.map((m) => m.id)).toContain('b') + }) + cleanup() + }) + + it('rejects modified module - reverts flowStore to beforeFlow state', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleBeforeA = createRawScriptModule('a', 'original-content') + const moduleAfterA = createRawScriptModule('a', 'modified-content') + const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleBeforeA)] }) + + const currentFlowValue: FlowValue = { modules: [deepClone(moduleAfterA)] } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + expect(manager.moduleActions['a']).toEqual({ action: 'modified', pending: true }) + + // Reject the modification + manager.rejectModule('a', flowStore) + flushSync() + + // After rejecting, flowStore should have the original content + const moduleA = flowStore.val.value.modules.find((m) => m.id === 'a') + expect((moduleA?.value as RawScript).content).toBe('original-content') + }) + cleanup() + }) + + it('rejects input schema changes - reverts flowStore schema', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const beforeSchema = { properties: { x: { type: 'string' } } } + const afterSchema = { properties: { x: { type: 'string' }, y: { type: 'number' } } } + const beforeFlow = createExtendedOpenFlow({ modules: [] }, beforeSchema) + + const flowStore = createFlowStore(createExtendedOpenFlow({ modules: [] }, afterSchema)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + manager.setCurrentInputSchema(afterSchema) + flushSync() + + expect(manager.moduleActions[SPECIAL_MODULE_IDS.INPUT]).toEqual({ + action: 'modified', + pending: true + }) + + // Reject the schema change + manager.rejectModule(SPECIAL_MODULE_IDS.INPUT, flowStore) + flushSync() + + // After rejecting, flowStore schema should match beforeFlow + expect(flowStore.val.schema).toEqual(beforeSchema) + }) + cleanup() + }) + + it('does not crash without flowStore', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content') + const moduleB = createRawScriptModule('b', 'content') + const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) + const afterFlow: FlowValue = { modules: [moduleA, moduleB] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + // Should not throw when flowStore is not provided + expect(() => manager.rejectModule('b')).not.toThrow() + }) + cleanup() + }) + + it('does nothing for module not in actions', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content') + const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) + const afterFlow: FlowValue = { modules: [moduleA] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + // No actions should exist + expect(Object.keys(manager.moduleActions)).toHaveLength(0) + + // Should not throw for non-existent module + expect(() => manager.rejectModule('nonexistent')).not.toThrow() + }) + cleanup() + }) + + it('rejects duplicate ID prefix (old__) for type changes - restores original and renames new', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + // Create a real type change: rawscript -> identity + const moduleBeforeA = createRawScriptModule('a', 'original-content') + const moduleAfterA = createIdentityModule('a') + const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleBeforeA)] }) + + const currentFlowValue: FlowValue = { modules: [deepClone(moduleAfterA)] } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + // Type change produces: 'a' (added) and 'old__a' (removed) + expect(manager.moduleActions['a']).toEqual({ action: 'added', pending: true }) + expect(manager.moduleActions[`${DUPLICATE_MODULE_PREFIX}a`]).toEqual({ + action: 'removed', + pending: true + }) + + // Reject the removal (old__a) - should restore original and rename the new one + manager.rejectModule(`${DUPLICATE_MODULE_PREFIX}a`, flowStore) + flushSync() + + const currentModules = flowStore.val.value.modules + + // The original module 'a' (rawscript) should be restored + const restoredOriginal = currentModules.find((m) => m.id === 'a') + expect(restoredOriginal).toBeDefined() + expect(restoredOriginal?.value.type).toBe('rawscript') + + // The new module should be renamed to 'new__a' so user can still accept/reject it + const renamedNew = currentModules.find((m) => m.id === `${NEW_MODULE_PREFIX}a`) + expect(renamedNew).toBeDefined() + expect(renamedNew?.value.type).toBe('identity') + }) + cleanup() + }) + }) + + describe('batch operations', () => { + it('acceptAll accepts all pending modules', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const moduleC = createRawScriptModule('c', 'content-c') + const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) + const afterFlow: FlowValue = { modules: [moduleA, moduleB, moduleC] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + expect(manager.moduleActions['b']).toBeDefined() + expect(manager.moduleActions['c']).toBeDefined() + + // Accept all + manager.acceptAll() + flushSync() + + // All modules should now be in beforeFlow + const beforeModules = manager.beforeFlow?.value.modules ?? [] + expect(beforeModules.map((m) => m.id)).toContain('b') + expect(beforeModules.map((m) => m.id)).toContain('c') + }) + cleanup() + }) + + it('acceptAll skips non-pending actions', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content') + const moduleB = createRawScriptModule('b', 'content') + const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) + const afterFlow: FlowValue = { modules: [moduleA, moduleB] } + + // Set editMode to false so actions are not pending + manager.setEditMode(false) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + // Action should exist but not be pending + expect(manager.moduleActions['b']?.pending).toBe(false) + + // acceptAll should skip non-pending + manager.acceptAll() + flushSync() + + // 'b' should still not be in beforeFlow + const beforeModules = manager.beforeFlow?.value.modules ?? [] + expect(beforeModules.map((m) => m.id)).not.toContain('b') + }) + cleanup() + }) + + it('rejectAll rejects all pending modules', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const moduleC = createRawScriptModule('c', 'content-c') + const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA)] }) + + const currentFlowValue: FlowValue = { + modules: [deepClone(moduleA), deepClone(moduleB), deepClone(moduleC)] + } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + expect(manager.moduleActions['b']).toBeDefined() + expect(manager.moduleActions['c']).toBeDefined() + + // Reject all + manager.rejectAll(flowStore) + flushSync() + + // Both added modules should be removed from flowStore + const currentModules = flowStore.val.value.modules + expect(currentModules.map((m) => m.id)).not.toContain('b') + expect(currentModules.map((m) => m.id)).not.toContain('c') + }) + cleanup() + }) + }) + + describe('edge cases', () => { + it('clearSnapshot clears all state', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content') + const moduleB = createRawScriptModule('b', 'content') + const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) + const afterFlow: FlowValue = { modules: [moduleA, moduleB] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + expect(manager.beforeFlow).toBeDefined() + expect(manager.currentFlow).toBeDefined() + expect(Object.keys(manager.moduleActions).length).toBeGreaterThan(0) + + manager.clearSnapshot() + flushSync() + + expect(manager.beforeFlow).toBeUndefined() + expect(manager.currentFlow).toBeUndefined() + expect(Object.keys(manager.moduleActions)).toHaveLength(0) + }) + cleanup() + }) + + it('revertToSnapshot restores entire flow', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'original') + const moduleB = createRawScriptModule('b', 'modified') + const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA)] }) + + const currentFlowValue: FlowValue = { modules: [deepClone(moduleB)] } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + // Revert to snapshot + manager.revertToSnapshot(flowStore) + flushSync() + + // flowStore should now be the beforeFlow + expect(flowStore.val.value.modules.map((m) => m.id)).toEqual(['a']) + expect((flowStore.val.value.modules[0].value as RawScript).content).toBe('original') + }) + cleanup() + }) + }) + + describe('module positioning', () => { + it('accept added module at beginning - inserts at correct position', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const moduleNew = createRawScriptModule('new', 'new at beginning') + + // beforeFlow: [a, b] + // afterFlow: [new, a, b] + const beforeFlow = createExtendedOpenFlow({ + modules: [deepClone(moduleA), deepClone(moduleB)] + }) + const afterFlow: FlowValue = { + modules: [deepClone(moduleNew), deepClone(moduleA), deepClone(moduleB)] + } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + expect(manager.moduleActions['new']).toEqual({ action: 'added', pending: true }) + + // Accept the added module + manager.acceptModule('new') + flushSync() + + // Verify 'new' is inserted at the beginning + const beforeModules = manager.beforeFlow?.value.modules ?? [] + expectModuleOrder(beforeModules, ['new', 'a', 'b']) + }) + cleanup() + }) + + it('accept added module in middle - inserts at correct position', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const moduleC = createRawScriptModule('c', 'content-c') + const moduleNew = createRawScriptModule('new', 'new in middle') + + // beforeFlow: [a, b, c] + // afterFlow: [a, new, b, c] + const beforeFlow = createExtendedOpenFlow({ + modules: [deepClone(moduleA), deepClone(moduleB), deepClone(moduleC)] + }) + const afterFlow: FlowValue = { + modules: [ + deepClone(moduleA), + deepClone(moduleNew), + deepClone(moduleB), + deepClone(moduleC) + ] + } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + expect(manager.moduleActions['new']).toEqual({ action: 'added', pending: true }) + + // Accept the added module + manager.acceptModule('new') + flushSync() + + // Verify 'new' is inserted at the correct middle position + const beforeModules = manager.beforeFlow?.value.modules ?? [] + expectModuleOrder(beforeModules, ['a', 'new', 'b', 'c']) + }) + cleanup() + }) + + it('reject removed module - restores at correct position', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const moduleC = createRawScriptModule('c', 'content-c') + + // beforeFlow: [a, b, c] + // afterFlow (currentFlow): [a, c] - 'b' removed + const beforeFlow = createExtendedOpenFlow({ + modules: [deepClone(moduleA), deepClone(moduleB), deepClone(moduleC)] + }) + + const currentFlowValue: FlowValue = { + modules: [deepClone(moduleA), deepClone(moduleC)] + } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + expect(manager.moduleActions['b']).toEqual({ action: 'removed', pending: true }) + + // Reject the removal - should restore 'b' at original position + manager.rejectModule('b', flowStore) + flushSync() + + // Verify 'b' is restored at the middle position + const currentModules = flowStore.val.value.modules + expectModuleOrder(currentModules, ['a', 'b', 'c']) + }) + cleanup() + }) + + it('accept added module inside loop - inserts at correct nested position', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const innerA = createRawScriptModule('inner_a', 'inner a') + const innerB = createRawScriptModule('inner_b', 'inner b') + const innerNew = createRawScriptModule('inner_new', 'new at beginning of loop') + + // beforeFlow: loop with [inner_a, inner_b] + // afterFlow: loop with [inner_new, inner_a, inner_b] + const beforeLoop = createForloopModule('loop1', [deepClone(innerA), deepClone(innerB)]) + const afterLoop = createForloopModule('loop1', [ + deepClone(innerNew), + deepClone(innerA), + deepClone(innerB) + ]) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeLoop] }) + const afterFlow: FlowValue = { modules: [afterLoop] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + expect(manager.moduleActions['inner_new']).toEqual({ action: 'added', pending: true }) + + // Accept the added nested module + manager.acceptModule('inner_new') + flushSync() + + // Verify 'inner_new' is inserted at beginning inside the loop + const beforeModules = manager.beforeFlow?.value.modules ?? [] + expect(beforeModules).toHaveLength(1) + const loopModule = beforeModules[0] + const loopModules = (loopModule.value as ForloopFlow).modules + expectModuleOrder(loopModules, ['inner_new', 'inner_a', 'inner_b']) + }) + cleanup() + }) + + it('reject removed module inside loop - restores at correct nested position', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const innerA = createRawScriptModule('inner_a', 'inner a') + const innerB = createRawScriptModule('inner_b', 'inner b') + const innerC = createRawScriptModule('inner_c', 'inner c') + + // beforeFlow: loop with [inner_a, inner_b, inner_c] + // afterFlow: loop with [inner_a, inner_c] - inner_b removed + const beforeLoop = createForloopModule('loop1', [ + deepClone(innerA), + deepClone(innerB), + deepClone(innerC) + ]) + const afterLoop = createForloopModule('loop1', [deepClone(innerA), deepClone(innerC)]) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeLoop] }) + const currentFlowValue: FlowValue = { modules: [afterLoop] } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + expect(manager.moduleActions['inner_b']).toEqual({ action: 'removed', pending: true }) + + // Reject the removal - should restore 'inner_b' at original nested position + manager.rejectModule('inner_b', flowStore) + flushSync() + + // Verify 'inner_b' is restored at the middle position inside the loop + const currentModules = flowStore.val.value.modules + expect(currentModules).toHaveLength(1) + const loopModule = currentModules[0] + const loopModules = (loopModule.value as ForloopFlow).modules + expectModuleOrder(loopModules, ['inner_a', 'inner_b', 'inner_c']) + }) + cleanup() + }) + + it('accept multiple added modules at different positions', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const moduleNew1 = createRawScriptModule('new1', 'new at start') + const moduleNew2 = createRawScriptModule('new2', 'new in middle') + const moduleNew3 = createRawScriptModule('new3', 'new at end') + + // beforeFlow: [a, b] + // afterFlow: [new1, a, new2, b, new3] + const beforeFlow = createExtendedOpenFlow({ + modules: [deepClone(moduleA), deepClone(moduleB)] + }) + const afterFlow: FlowValue = { + modules: [ + deepClone(moduleNew1), + deepClone(moduleA), + deepClone(moduleNew2), + deepClone(moduleB), + deepClone(moduleNew3) + ] + } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + // Accept all added modules one by one + manager.acceptModule('new1') + flushSync() + manager.acceptModule('new2') + flushSync() + manager.acceptModule('new3') + flushSync() + + // Verify all modules are at correct positions + const beforeModules = manager.beforeFlow?.value.modules ?? [] + expectModuleOrder(beforeModules, ['new1', 'a', 'new2', 'b', 'new3']) + }) + cleanup() + }) + + it('reject multiple removed modules - restores all at correct positions', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'first') + const moduleB = createRawScriptModule('b', 'second') + const moduleC = createRawScriptModule('c', 'third') + const moduleD = createRawScriptModule('d', 'fourth') + const moduleE = createRawScriptModule('e', 'fifth') + + // beforeFlow: [a, b, c, d, e] + // afterFlow: [b, d] - a, c, e removed + const beforeFlow = createExtendedOpenFlow({ + modules: [ + deepClone(moduleA), + deepClone(moduleB), + deepClone(moduleC), + deepClone(moduleD), + deepClone(moduleE) + ] + }) + + const currentFlowValue: FlowValue = { + modules: [deepClone(moduleB), deepClone(moduleD)] + } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + // Reject removals one by one + manager.rejectModule('a', flowStore) + flushSync() + manager.rejectModule('c', flowStore) + flushSync() + manager.rejectModule('e', flowStore) + flushSync() + + // Verify all modules are restored at correct positions + const currentModules = flowStore.val.value.modules + expectModuleOrder(currentModules, ['a', 'b', 'c', 'd', 'e']) + }) + cleanup() + }) + }) + + describe('branch operations', () => { + describe('branchone', () => { + it('accept added module in default branch - inserts at correct position', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const defaultA = createRawScriptModule('default_a', 'default step a') + const defaultB = createRawScriptModule('default_b', 'default step b') + + // beforeFlow: branch with [default_a] in default + // afterFlow: branch with [default_a, default_b] in default + const beforeBranch = createBranchOneModule('branch1', [deepClone(defaultA)], []) + const afterBranch = createBranchOneModule( + 'branch1', + [deepClone(defaultA), deepClone(defaultB)], + [] + ) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) + const afterFlow: FlowValue = { modules: [afterBranch] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + expect(manager.moduleActions['default_b']).toEqual({ action: 'added', pending: true }) + + // Accept the added module + manager.acceptModule('default_b') + flushSync() + + // Verify 'default_b' is inserted at correct position in default branch + const beforeModules = manager.beforeFlow?.value.modules ?? [] + expect(beforeModules).toHaveLength(1) + const branchModule = beforeModules[0] + const branchDefault = (branchModule.value as BranchOne).default + expectModuleOrder(branchDefault, ['default_a', 'default_b']) + }) + cleanup() + }) + + it('reject removed module from default branch - restores at correct position', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const defaultA = createRawScriptModule('default_a', 'default step a') + const defaultB = createRawScriptModule('default_b', 'default step b') + const defaultC = createRawScriptModule('default_c', 'default step c') + + // beforeFlow: branch with [default_a, default_b, default_c] in default + // afterFlow: branch with [default_a, default_c] in default - default_b removed + const beforeBranch = createBranchOneModule( + 'branch1', + [deepClone(defaultA), deepClone(defaultB), deepClone(defaultC)], + [] + ) + const afterBranch = createBranchOneModule( + 'branch1', + [deepClone(defaultA), deepClone(defaultC)], + [] + ) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) + const currentFlowValue: FlowValue = { modules: [afterBranch] } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + expect(manager.moduleActions['default_b']).toEqual({ action: 'removed', pending: true }) + + // Reject the removal - should restore 'default_b' at original position + manager.rejectModule('default_b', flowStore) + flushSync() + + // Verify 'default_b' is restored at the middle position in default branch + const currentModules = flowStore.val.value.modules + expect(currentModules).toHaveLength(1) + const branchModule = currentModules[0] + const branchDefault = (branchModule.value as BranchOne).default + expectModuleOrder(branchDefault, ['default_a', 'default_b', 'default_c']) + }) + cleanup() + }) + + it('accept added module in conditional branch - inserts at correct position', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const branchModuleA = createRawScriptModule('branch_a', 'branch step a') + const branchModuleB = createRawScriptModule('branch_b', 'branch step b') + + // beforeFlow: branch with [branch_a] in conditional branch + // afterFlow: branch with [branch_a, branch_b] in conditional branch + const beforeBranch = createBranchOneModule( + 'branch1', + [], + [{ expr: 'x > 0', modules: [deepClone(branchModuleA)] }] + ) + const afterBranch = createBranchOneModule( + 'branch1', + [], + [{ expr: 'x > 0', modules: [deepClone(branchModuleA), deepClone(branchModuleB)] }] + ) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) + const afterFlow: FlowValue = { modules: [afterBranch] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + expect(manager.moduleActions['branch_b']).toEqual({ action: 'added', pending: true }) + + // Accept the added module + manager.acceptModule('branch_b') + flushSync() + + // Verify 'branch_b' is inserted in the conditional branch + const beforeModules = manager.beforeFlow?.value.modules ?? [] + expect(beforeModules).toHaveLength(1) + const branchModule = beforeModules[0] + const branches = (branchModule.value as BranchOne).branches + expect(branches).toHaveLength(1) + expectModuleOrder(branches[0].modules, ['branch_a', 'branch_b']) + }) + cleanup() + }) + + it('reject removed module from conditional branch - restores at correct position', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const branchModuleA = createRawScriptModule('branch_a', 'branch step a') + const branchModuleB = createRawScriptModule('branch_b', 'branch step b') + + // beforeFlow: branch with [branch_a, branch_b] in conditional branch + // afterFlow: branch with [branch_a] in conditional branch - branch_b removed + const beforeBranch = createBranchOneModule( + 'branch1', + [], + [{ expr: 'x > 0', modules: [deepClone(branchModuleA), deepClone(branchModuleB)] }] + ) + const afterBranch = createBranchOneModule( + 'branch1', + [], + [{ expr: 'x > 0', modules: [deepClone(branchModuleA)] }] + ) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) + const currentFlowValue: FlowValue = { modules: [afterBranch] } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + expect(manager.moduleActions['branch_b']).toEqual({ action: 'removed', pending: true }) + + // Reject the removal + manager.rejectModule('branch_b', flowStore) + flushSync() + + // Verify 'branch_b' is restored in the conditional branch + const currentModules = flowStore.val.value.modules + expect(currentModules).toHaveLength(1) + const branchModule = currentModules[0] + const branches = (branchModule.value as BranchOne).branches + expect(branches).toHaveLength(1) + expectModuleOrder(branches[0].modules, ['branch_a', 'branch_b']) + }) + cleanup() + }) + + it('accept modified module in branch - updates content correctly', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const branchModuleBefore = createRawScriptModule('branch_a', 'original content') + const branchModuleAfter = createRawScriptModule('branch_a', 'modified content') + + // beforeFlow: branch with original module + // afterFlow: branch with modified module + const beforeBranch = createBranchOneModule('branch1', [deepClone(branchModuleBefore)], []) + const afterBranch = createBranchOneModule('branch1', [deepClone(branchModuleAfter)], []) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) + const afterFlow: FlowValue = { modules: [afterBranch] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + expect(manager.moduleActions['branch_a']).toEqual({ action: 'modified', pending: true }) + + // Accept the modification + manager.acceptModule('branch_a') + flushSync() + + // Verify content is updated + const beforeModules = manager.beforeFlow?.value.modules ?? [] + const branchModule = beforeModules[0] + const branchDefault = (branchModule.value as BranchOne).default + const moduleA = branchDefault.find((m) => m.id === 'branch_a') + expect((moduleA?.value as RawScript).content).toBe('modified content') + }) + cleanup() + }) + + it('reject entire conditional branch removal - restores the branch with its modules', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const defaultModule = createRawScriptModule('default_mod', 'default') + const branch1ModuleA = createRawScriptModule('b1_a', 'branch 1 a') + const branch2ModuleA = createRawScriptModule('b2_a', 'branch 2 a') + const branch2ModuleB = createRawScriptModule('b2_b', 'branch 2 b') + + // beforeFlow: branch with 2 conditional branches + // afterFlow: branch with only 1 conditional branch - second branch removed + const beforeBranch = createBranchOneModule( + 'branch1', + [deepClone(defaultModule)], + [ + { expr: 'x > 0', modules: [deepClone(branch1ModuleA)] }, + { expr: 'x < 0', modules: [deepClone(branch2ModuleA), deepClone(branch2ModuleB)] } + ] + ) + const afterBranch = createBranchOneModule( + 'branch1', + [deepClone(defaultModule)], + [{ expr: 'x > 0', modules: [deepClone(branch1ModuleA)] }] + ) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) + const currentFlowValue: FlowValue = { modules: [afterBranch] } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + // Both modules from the removed branch should be marked as removed + expect(manager.moduleActions['b2_a']).toEqual({ action: 'removed', pending: true }) + expect(manager.moduleActions['b2_b']).toEqual({ action: 'removed', pending: true }) + + // Reject both removals to restore the entire branch + manager.rejectModule('b2_a', flowStore) + flushSync() + manager.rejectModule('b2_b', flowStore) + flushSync() + + // Verify the second branch is restored with both modules + const currentModules = flowStore.val.value.modules + expect(currentModules).toHaveLength(1) + const branchModule = currentModules[0] + const branches = (branchModule.value as BranchOne).branches + + // Should have 2 branches again + expect(branches).toHaveLength(2) + expectModuleOrder(branches[0].modules, ['b1_a']) + expectModuleOrder(branches[1].modules, ['b2_a', 'b2_b']) + }) + cleanup() + }) + }) + + describe('branchall', () => { + it('accept added module in parallel branch - inserts at correct position', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const parallel1A = createRawScriptModule('p1_a', 'parallel 1 a') + const parallel2A = createRawScriptModule('p2_a', 'parallel 2 a') + const parallel2B = createRawScriptModule('p2_b', 'parallel 2 b - new') + + // beforeFlow: branchall with [p1_a] and [p2_a] + // afterFlow: branchall with [p1_a] and [p2_a, p2_b] + const beforeBranchAll = createBranchAllModule('branchall1', [ + { modules: [deepClone(parallel1A)] }, + { modules: [deepClone(parallel2A)] } + ]) + const afterBranchAll = createBranchAllModule('branchall1', [ + { modules: [deepClone(parallel1A)] }, + { modules: [deepClone(parallel2A), deepClone(parallel2B)] } + ]) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranchAll] }) + const afterFlow: FlowValue = { modules: [afterBranchAll] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + expect(manager.moduleActions['p2_b']).toEqual({ action: 'added', pending: true }) + + // Accept the added module + manager.acceptModule('p2_b') + flushSync() + + // Verify 'p2_b' is inserted in the second parallel branch + const beforeModules = manager.beforeFlow?.value.modules ?? [] + expect(beforeModules).toHaveLength(1) + const branchAllModule = beforeModules[0] + const branches = (branchAllModule.value as BranchAll).branches + expect(branches).toHaveLength(2) + expectModuleOrder(branches[0].modules, ['p1_a']) + expectModuleOrder(branches[1].modules, ['p2_a', 'p2_b']) + }) + cleanup() + }) + + it('reject removed module from parallel branch - restores at correct position', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const parallel1A = createRawScriptModule('p1_a', 'parallel 1 a') + const parallel1B = createRawScriptModule('p1_b', 'parallel 1 b') + const parallel2A = createRawScriptModule('p2_a', 'parallel 2 a') + + // beforeFlow: branchall with [p1_a, p1_b] and [p2_a] + // afterFlow: branchall with [p1_a] and [p2_a] - p1_b removed + const beforeBranchAll = createBranchAllModule('branchall1', [ + { modules: [deepClone(parallel1A), deepClone(parallel1B)] }, + { modules: [deepClone(parallel2A)] } + ]) + const afterBranchAll = createBranchAllModule('branchall1', [ + { modules: [deepClone(parallel1A)] }, + { modules: [deepClone(parallel2A)] } + ]) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranchAll] }) + const currentFlowValue: FlowValue = { modules: [afterBranchAll] } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + expect(manager.moduleActions['p1_b']).toEqual({ action: 'removed', pending: true }) + + // Reject the removal + manager.rejectModule('p1_b', flowStore) + flushSync() + + // Verify 'p1_b' is restored in the first parallel branch + const currentModules = flowStore.val.value.modules + expect(currentModules).toHaveLength(1) + const branchAllModule = currentModules[0] + const branches = (branchAllModule.value as BranchAll).branches + expect(branches).toHaveLength(2) + expectModuleOrder(branches[0].modules, ['p1_a', 'p1_b']) + expectModuleOrder(branches[1].modules, ['p2_a']) + }) + cleanup() + }) + + it('reject entire parallel branch removal - restores the branch with its modules', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const parallel1A = createRawScriptModule('p1_a', 'parallel 1 a') + const parallel1B = createRawScriptModule('p1_b', 'parallel 1 b') + const parallel2A = createRawScriptModule('p2_a', 'parallel 2 a') + + // beforeFlow: branchall with 2 parallel branches + // afterFlow: branchall with only 1 parallel branch - second branch removed + const beforeBranchAll = createBranchAllModule('branchall1', [ + { modules: [deepClone(parallel1A), deepClone(parallel1B)] }, + { modules: [deepClone(parallel2A)] } + ]) + const afterBranchAll = createBranchAllModule('branchall1', [ + { modules: [deepClone(parallel1A), deepClone(parallel1B)] } + ]) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranchAll] }) + const currentFlowValue: FlowValue = { modules: [afterBranchAll] } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + // Module from the removed branch should be marked as removed + expect(manager.moduleActions['p2_a']).toEqual({ action: 'removed', pending: true }) + + // Reject the removal to restore the entire branch + manager.rejectModule('p2_a', flowStore) + flushSync() + + // Verify the second parallel branch is restored + const currentModules = flowStore.val.value.modules + expect(currentModules).toHaveLength(1) + const branchAllModule = currentModules[0] + const branches = (branchAllModule.value as BranchAll).branches + + // Should have 2 branches again + expect(branches).toHaveLength(2) + expectModuleOrder(branches[0].modules, ['p1_a', 'p1_b']) + expectModuleOrder(branches[1].modules, ['p2_a']) + }) + cleanup() + }) + }) + }) + + describe('module movement', () => { + it('accept module moved from root to loop - accepts the addition in loop', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const emptyLoop = createForloopModule('loop1', []) + const loopWithB = createForloopModule('loop1', [deepClone(moduleB)]) + + // beforeFlow: [a, b, loop(empty)] + // afterFlow: [a, loop(b)] + const beforeFlow = createExtendedOpenFlow({ + modules: [deepClone(moduleA), deepClone(moduleB), deepClone(emptyLoop)] + }) + const afterFlow: FlowValue = { + modules: [deepClone(moduleA), deepClone(loopWithB)] + } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + // 'b' should be detected as moved (removed from root, added in loop) + expect(manager.moduleActions['b']).toEqual({ action: 'added', pending: true }) + expect(manager.moduleActions['old__b']).toEqual({ action: 'removed', pending: true }) + + // Accept the added 'b' in loop + manager.acceptModule('b') + flushSync() + + // After accepting, beforeFlow should have 'b' inside the loop + const beforeModules = manager.beforeFlow?.value.modules ?? [] + const loopModule = beforeModules.find((m) => m.id === 'loop1') + const loopModules = (loopModule?.value as ForloopFlow).modules + expect(loopModules.some((m) => m.id === 'b')).toBe(true) + }) + cleanup() + }) + + it('accept module moved from root to loop - accepts the removal at root', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const emptyLoop = createForloopModule('loop1', []) + const loopWithB = createForloopModule('loop1', [deepClone(moduleB)]) + + // beforeFlow: [a, b, loop(empty)] + // afterFlow: [a, loop(b)] + const beforeFlow = createExtendedOpenFlow({ + modules: [deepClone(moduleA), deepClone(moduleB), deepClone(emptyLoop)] + }) + const afterFlow: FlowValue = { + modules: [deepClone(moduleA), deepClone(loopWithB)] + } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + // Accept the removed 'old__b' at root + manager.acceptModule('old__b') + flushSync() + + // After accepting removal, 'b' should no longer be at root in beforeFlow + const beforeModules = manager.beforeFlow?.value.modules ?? [] + const rootIds = beforeModules.map((m) => m.id) + expect(rootIds).not.toContain('b') + }) + cleanup() + }) + + it('reject module moved from root to loop - rejects the addition', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const emptyLoop = createForloopModule('loop1', []) + const loopWithB = createForloopModule('loop1', [deepClone(moduleB)]) + + // beforeFlow: [a, b, loop(empty)] + const beforeFlow = createExtendedOpenFlow({ + modules: [deepClone(moduleA), deepClone(moduleB), deepClone(emptyLoop)] + }) + + // currentFlow (flowStore): [a, loop(b)] + const currentFlowValue: FlowValue = { + modules: [deepClone(moduleA), deepClone(loopWithB)] + } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + // Reject the added 'b' in loop - should remove it from flowStore + manager.rejectModule('b', flowStore) + flushSync() + + // 'b' should no longer be in the loop in flowStore + const currentModules = flowStore.val.value.modules + const loopModule = currentModules.find((m) => m.id === 'loop1') + const loopModules = (loopModule?.value as ForloopFlow).modules + expect(loopModules.some((m) => m.id === 'b')).toBe(false) + }) + cleanup() + }) + + it('reject module moved from root to loop - rejects the removal (restores at root)', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'content-a') + const moduleB = createRawScriptModule('b', 'content-b') + const emptyLoop = createForloopModule('loop1', []) + const loopWithB = createForloopModule('loop1', [deepClone(moduleB)]) + + // beforeFlow: [a, b, loop(empty)] + const beforeFlow = createExtendedOpenFlow({ + modules: [deepClone(moduleA), deepClone(moduleB), deepClone(emptyLoop)] + }) + + // currentFlow (flowStore): [a, loop(b)] + const currentFlowValue: FlowValue = { + modules: [deepClone(moduleA), deepClone(loopWithB)] + } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + // Reject the removed 'old__b' - restores original at root, renames new to 'new__b' + manager.rejectModule('old__b', flowStore) + flushSync() + + // 'b' should be restored at root level in flowStore + const currentModules = flowStore.val.value.modules + const rootIds = currentModules.map((m) => m.id) + expect(rootIds).toContain('b') + + // The module in the loop should now be 'new__b' + const loopModule = currentModules.find((m) => m.id === 'loop1') + const loopModules = (loopModule?.value as ForloopFlow).modules + expect(loopModules.some((m) => m.id === `${NEW_MODULE_PREFIX}b`)).toBe(true) + }) + cleanup() + }) + + it('accept module moved between branches', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'step a') + + // beforeFlow: branch with 'a' in first conditional branch + const beforeBranch = createBranchOneModule( + 'branch1', + [], + [ + { expr: 'x > 0', modules: [deepClone(moduleA)] }, + { expr: 'x < 0', modules: [] } + ] + ) + // afterFlow: branch with 'a' in second conditional branch + const afterBranch = createBranchOneModule( + 'branch1', + [], + [ + { expr: 'x > 0', modules: [] }, + { expr: 'x < 0', modules: [deepClone(moduleA)] } + ] + ) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) + const afterFlow: FlowValue = { modules: [afterBranch] } + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(afterFlow) + flushSync() + + // 'a' is moved - should be removed from branch 0 and added to branch 1 + expect(manager.moduleActions['a']).toEqual({ action: 'added', pending: true }) + expect(manager.moduleActions['old__a']).toEqual({ action: 'removed', pending: true }) + + // Accept both the addition and removal + manager.acceptModule('a') + flushSync() + manager.acceptModule('old__a') + flushSync() + + // beforeFlow should now have 'a' in branch 1, not in branch 0 + const beforeModules = manager.beforeFlow?.value.modules ?? [] + const branchModule = beforeModules.find((m) => m.id === 'branch1') + const branches = (branchModule?.value as BranchOne).branches + expect(branches[0].modules.some((m) => m.id === 'a')).toBe(false) + expect(branches[1].modules.some((m) => m.id === 'a')).toBe(true) + }) + cleanup() + }) + + it('reject module moved between branches - restores original position', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleA = createRawScriptModule('a', 'step a') + + // beforeFlow: branch with 'a' in first conditional branch + const beforeBranch = createBranchOneModule( + 'branch1', + [], + [ + { expr: 'x > 0', modules: [deepClone(moduleA)] }, + { expr: 'x < 0', modules: [] } + ] + ) + // afterFlow: branch with 'a' in second conditional branch + const afterBranch = createBranchOneModule( + 'branch1', + [], + [ + { expr: 'x > 0', modules: [] }, + { expr: 'x < 0', modules: [deepClone(moduleA)] } + ] + ) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) + const currentFlowValue: FlowValue = { modules: [afterBranch] } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + // Reject the removal from branch 0 - restores original 'a', renames new to 'new__a' + manager.rejectModule('old__a', flowStore) + flushSync() + + // Accept the addition in branch 1 + manager.acceptModule('a') + flushSync() + + // flowStore should now have 'a' in branch 0 (original), 'new__a' in branch 1 (renamed new) + const currentModules = flowStore.val.value.modules + const branchModule = currentModules.find((m) => m.id === 'branch1') + const branches = (branchModule?.value as BranchOne).branches + expect(branches[0].modules.some((m) => m.id === 'a')).toBe(true) + expect(branches[1].modules.some((m) => m.id === `${NEW_MODULE_PREFIX}a`)).toBe(true) + }) + cleanup() + }) + + it('prevents bug when rejecting moved module removal (branchall scenario)', () => { + const cleanup = $effect.root(() => { + const manager = createFlowDiffManager({ testMode: true }) + + const moduleR = createRawScriptModule('r', 'module r content') + + // beforeFlow: 'r' in branch 2 + const beforeBranchAll = createBranchAllModule('branchall1', [ + { modules: [] }, // branch 0 + { modules: [] }, // branch 1 + { modules: [deepClone(moduleR)] } // branch 2 + ]) + + // currentFlow: 'r' moved to branch 0 + const afterBranchAll = createBranchAllModule('branchall1', [ + { modules: [deepClone(moduleR)] }, // branch 0 + { modules: [] }, // branch 1 + { modules: [] } // branch 2 + ]) + + const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranchAll] }) + const currentFlowValue: FlowValue = { modules: [afterBranchAll] } + const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) + + manager.setEditMode(true) + manager.setBeforeFlow(beforeFlow) + manager.setCurrentFlow(flowStore.val.value) + flushSync() + + // Verify initial state: 'r' is added at branch 0, 'old__r' is removed from branch 2 + expect(manager.moduleActions['r']).toEqual({ action: 'added', pending: true }) + expect(manager.moduleActions['old__r']).toEqual({ action: 'removed', pending: true }) + + // User rejects the removal - wants to keep module at original location (branch 2) + manager.rejectModule('old__r', flowStore) + flushSync() + + // After rejection, verify the current behavior: + // 1. Original module should be restored at branch 2 with id 'r' + // 2. New module at branch 0 should be renamed to 'new__r' + const currentModules = flowStore.val.value.modules + const branchAllModule = currentModules.find((m) => m.id === 'branchall1') + const branches = (branchAllModule?.value as BranchAll).branches + + // Branch 0 should have 'new__r' (renamed new module) + expect(branches[0].modules.some((m) => m.id === `${NEW_MODULE_PREFIX}r`)).toBe(true) + + // Branch 2 should have 'r' (restored original) + expect(branches[2].modules.some((m) => m.id === 'r')).toBe(true) + }) + cleanup() + }) + }) +}) diff --git a/frontend/src/lib/components/flows/flowDiffManager.svelte.ts b/frontend/src/lib/components/flows/flowDiffManager.svelte.ts new file mode 100644 index 0000000000..976a588266 --- /dev/null +++ b/frontend/src/lib/components/flows/flowDiffManager.svelte.ts @@ -0,0 +1,543 @@ +/** + * Flow Diff Manager + * + * A reusable store for managing flow diff state, module actions, and accept/reject operations. + * This decouples diff management from specific UI components (like AI chat) and makes it + * available for any use case that needs to track and apply flow changes. + */ + +import type { ExtendedOpenFlow } from './types' +import type { FlowModule, FlowValue } from '$lib/gen' +import type { ModuleActionInfo } from './flowDiff' +import { + buildFlowTimeline, + insertModuleIntoFlow, + findModuleParent, + locationsEqual, + DUPLICATE_MODULE_PREFIX, + NEW_MODULE_PREFIX +} from './flowDiff' +import { refreshStateStore } from '$lib/svelte5Utils.svelte' +import type { StateStore } from '$lib/utils' +import { getIndexInNestedModules } from '../copilot/chat/flow/utils' +import { dfs } from './previousResults' +import type DiffDrawer from '../DiffDrawer.svelte' +import { SPECIAL_MODULE_IDS } from '../copilot/chat/shared' + +export type FlowDiffManager = ReturnType + +/** + * Options for computing diff + */ +export type ComputeDiffOptions = { + /** Mark all changes as pending (requiring user approval) */ + markAsPending?: boolean + /** Mark removed modules as shadowed instead of removed (for visualization) */ + markRemovedAsShadowed?: boolean +} + +/** + * Creates a flow diff manager instance + */ +function createSkeletonModule(module: FlowModule): FlowModule { + const clone = JSON.parse(JSON.stringify(module)) + if (clone.value.type === 'forloopflow' || clone.value.type === 'whileloopflow') { + clone.value.modules = [] + } else if (clone.value.type === 'branchone') { + clone.value.default = [] + clone.value.branches.forEach((b: any) => (b.modules = [])) + } else if (clone.value.type === 'branchall') { + clone.value.branches.forEach((b: any) => (b.modules = [])) + } else if (clone.value.type === 'aiagent') { + clone.value.tools = [] + } + return clone +} + +export function createFlowDiffManager({ testMode = false } = {}) { + // State: snapshot of flow before changes + let beforeFlow = $state(undefined) + + // State: current flow (after changes) + let currentFlow = $state(undefined) + + // State: merged flow containing both original and modified/removed modules + let mergedFlow = $state(undefined) + + // State: current input schema + let currentInputSchema = $state | undefined>(undefined) + + // State: whether to mark removed modules as shadowed (for side-by-side view) + let markRemovedAsShadowed = $state(false) + + // State: whether to allow accepting/rejecting changes to the flow + let editMode = $state(false) + + // State: module actions tracking changes (added/modified/removed/shadowed) + let moduleActions = $state>({}) + + // Reference to DiffDrawer component for showing module diffs (not reactive) + let diffDrawer: DiffDrawer | undefined = undefined + + // Derived: whether there are any pending changes + const hasPendingChanges = $derived(Object.values(moduleActions).some((info) => info.pending)) + + // Auto-compute diff when beforeFlow or currentFlow changes + $effect(() => { + if (beforeFlow && currentFlow) { + const timeline = buildFlowTimeline(beforeFlow.value, currentFlow, { + markRemovedAsShadowed: markRemovedAsShadowed, + markAsPending: editMode + }) + + // Store the merged flow for rendering + mergedFlow = timeline.mergedFlow + + // Update module actions + const newActions = { ...timeline.afterActions } + + // Check for input schema changes + if (beforeFlow.schema && currentInputSchema) { + const schemaChanged = + JSON.stringify(beforeFlow.schema) !== JSON.stringify(currentInputSchema) + if (schemaChanged) { + newActions[SPECIAL_MODULE_IDS.INPUT] = { + action: 'modified', + pending: editMode + } + } + } + + updateModuleActions(newActions) + + // If no more actions, clear the snapshot (exit diff mode) + if (Object.keys(newActions).length === 0 && !testMode) { + clearSnapshot() + } + } else if (!beforeFlow) { + // Clear module actions and merged flow when no snapshot + mergedFlow = undefined + updateModuleActions({}) + } + }) + + /** + * Helper to update moduleActions and notify listeners + */ + function updateModuleActions(newActions: Record) { + moduleActions = newActions + } + + /** + * Set the before flow snapshot for diff computation + */ + function setBeforeFlow(flow: ExtendedOpenFlow | undefined) { + beforeFlow = flow + } + + /** + * Set the current flow state for diff computation + */ + function setCurrentFlow(flow: FlowValue | undefined) { + currentFlow = flow + } + + /** + * Set the current input schema for tracking schema changes + */ + function setCurrentInputSchema(schema: Record | undefined) { + currentInputSchema = schema + } + + /** + * Set whether to mark removed modules as shadowed (for side-by-side view) + */ + function setMarkRemovedAsShadowed(value: boolean) { + markRemovedAsShadowed = value + } + + /** + * Set whether to edit the flow + */ + function setEditMode(value: boolean) { + editMode = value + } + + /** + * Clear the snapshot and all module actions + */ + function clearSnapshot() { + beforeFlow = undefined + currentFlow = undefined + mergedFlow = undefined + currentInputSchema = undefined + updateModuleActions({}) + } + + /** + * Set module actions directly (useful when actions are computed elsewhere) + */ + function setModuleActions(actions: Record) { + updateModuleActions(actions) + } + + /** + * Helper to get a module from a flow by ID + */ + function getModuleFromFlow(id: string, flow: ExtendedOpenFlow): FlowModule | undefined { + if (flow.value.preprocessor_module?.id === id) { + return flow.value.preprocessor_module + } else if (flow.value.failure_module?.id === id) { + return flow.value.failure_module + } else { + return dfs(id, flow, false)[0] + } + } + + /** + * Internal helper to delete a module from a flow object + * Returns true if the module was found and deleted, false otherwise + */ + function deleteModuleInternal(id: string, flow: ExtendedOpenFlow): boolean { + if (flow.value.preprocessor_module?.id === id) { + flow.value.preprocessor_module = undefined + return true + } else if (flow.value.failure_module?.id === id) { + flow.value.failure_module = undefined + return true + } else { + const result = getIndexInNestedModules(flow, id) + if (!result) { + // Module not found (may have been deleted along with a parent) + return false + } + const { modules } = result + const index = modules.findIndex((m) => m.id === id) + if (index >= 0) { + modules.splice(index, 1) + return true + } + return false + } + } + + /** + * Helper to delete a module from the flow + * Returns true if the module was found and deleted, false otherwise + */ + function deleteModuleFromFlow( + id: string, + flowStore: StateStore, + selectNextIdFn?: (id: string) => void + ): boolean { + selectNextIdFn?.(id) + const deleted = deleteModuleInternal(id, flowStore.val) + if (deleted) { + refreshStateStore(flowStore) + } + return deleted + } + + /** + * Accept a module action (keep the changes) + * Removes the action from tracking after acceptance + */ + function acceptModule(id: string, flowStore?: StateStore, asSkeleton = false) { + if (!beforeFlow || !currentFlow) { + console.warn('Cannot accept module without beforeFlow and currentFlow snapshots') + return + } + + const info = moduleActions[id] + if (!info) return + + const actualId = id.startsWith(DUPLICATE_MODULE_PREFIX) + ? id.substring(DUPLICATE_MODULE_PREFIX.length) + : id + + if (id === SPECIAL_MODULE_IDS.INPUT) { + // Accept input schema changes: update beforeFlow to match currentInputSchema + if (beforeFlow.schema && currentInputSchema) { + beforeFlow.schema = JSON.parse(JSON.stringify(currentInputSchema)) + } + } else if (info.action === 'removed') { + // Removed in after: Remove from beforeFlow + deleteModuleInternal(actualId, beforeFlow) + } else if (info.action === 'added') { + // Added in after: Add to beforeFlow + + // Check if parent exists in beforeFlow; if not, recursively accept parent first. + const parentLoc = findModuleParent(currentFlow, actualId) + if ( + parentLoc && + parentLoc.type !== 'root' && + parentLoc.type !== 'failure' && + parentLoc.type !== 'preprocessor' + ) { + const parentInBefore = getModuleFromFlow(parentLoc.parentId, beforeFlow) + if (!parentInBefore) { + // Parent is missing in beforeFlow. It must be pending acceptance. + // Accept as skeleton to avoid auto-accepting all siblings. + acceptModule(parentLoc.parentId, flowStore, true) + } + } + + // Use insertModuleIntoFlow targeting beforeFlow, sourcing position from currentFlow + let module = getModuleFromFlow(actualId, { + value: currentFlow, + summary: '' + } as ExtendedOpenFlow) + + if (module) { + // Check if module already exists in beforeFlow (could be a skeleton from earlier acceptance) + const existingModule = getModuleFromFlow(actualId, beforeFlow) + + if (existingModule) { + // Module exists in beforeFlow - check if it's in the same location + const beforeLocation = findModuleParent(beforeFlow.value, actualId) + const afterLocation = findModuleParent(currentFlow, actualId) + + // Compare locations - if different, this is a move and we need to insert at new location + const sameLocation = locationsEqual(beforeLocation, afterLocation) + + if (sameLocation) { + // Module is in the same location, update it in-place + const moduleToApply = asSkeleton ? createSkeletonModule(module) : module + Object.keys(existingModule).forEach((k) => delete (existingModule as any)[k]) + Object.assign(existingModule, $state.snapshot(moduleToApply)) + } else { + // Module is being moved - insert at new location (the old copy will be removed when old__id is accepted) + const moduleToInsert = asSkeleton ? createSkeletonModule(module) : module + insertModuleIntoFlow( + beforeFlow.value, + $state.snapshot(moduleToInsert), + currentFlow, + actualId + ) + } + } else { + // Module doesn't exist, insert it + const moduleToInsert = asSkeleton ? createSkeletonModule(module) : module + insertModuleIntoFlow( + beforeFlow.value, + $state.snapshot(moduleToInsert), + currentFlow, + actualId + ) + } + } + } else if (info.action === 'modified') { + // Modified: Apply modifications to beforeFlow module + const beforeModule = getModuleFromFlow(actualId, beforeFlow) + const afterModule = getModuleFromFlow(actualId, { + value: currentFlow, + summary: '' + } as ExtendedOpenFlow) + + if (beforeModule && afterModule) { + Object.keys(beforeModule).forEach((k) => delete (beforeModule as any)[k]) + Object.assign(beforeModule, $state.snapshot(afterModule)) + } + } + } + + /** + * Reject a module action (revert the changes) + * Removes the action from tracking after rejection + */ + function rejectModule(id: string, flowStore?: StateStore) { + if (!beforeFlow) { + throw new Error('Cannot reject module without a beforeFlow snapshot') + } + + const actualId = id.startsWith(DUPLICATE_MODULE_PREFIX) + ? id.substring(DUPLICATE_MODULE_PREFIX.length) + : id + const info = moduleActions[id] + + if (!info) return + + // Only perform revert operations if flowStore is provided + if (flowStore) { + if (id === SPECIAL_MODULE_IDS.INPUT) { + // Revert input schema changes + flowStore.val.schema = beforeFlow.schema + currentInputSchema = flowStore.val.schema + } else if (info.action === 'added') { + // Added in after: Remove from flowStore (currentFlow) + // deleteModuleFromFlow handles the case where the module was already deleted (e.g., with its parent) + deleteModuleFromFlow(actualId, flowStore) + } else if (info.action === 'removed') { + // Removed in after: Restore to flowStore (currentFlow) + // Source from beforeFlow + const oldModule = getModuleFromFlow(actualId, beforeFlow) + if (oldModule) { + // For type changes (old__ prefix), rename the new module to avoid ID conflict + if (id.startsWith(DUPLICATE_MODULE_PREFIX)) { + const existingNew = getModuleFromFlow(actualId, flowStore.val) + if (existingNew) { + existingNew.id = `${NEW_MODULE_PREFIX}${actualId}` + } + } + insertModuleIntoFlow( + flowStore.val.value, + $state.snapshot(oldModule), + beforeFlow.value, + actualId + ) + } + refreshStateStore(flowStore) + } else if (info.action === 'modified') { + // Modified: Revert modifications in flowStore (currentFlow) + const oldModule = getModuleFromFlow(actualId, beforeFlow) + const newModule = getModuleFromFlow(actualId, flowStore.val) + + if (oldModule && newModule) { + Object.keys(newModule).forEach((k) => delete (newModule as any)[k]) + Object.assign(newModule, $state.snapshot(oldModule)) + } + refreshStateStore(flowStore) + } + + currentFlow = flowStore.val.value + } + + // Note: The $effect will automatically recompute the diff, clearing the action + // since flowStore (currentFlow) now matches beforeFlow for this module. + } + + /** + * Accept all pending module actions + */ + function acceptAll(flowStore?: StateStore) { + const ids = Object.keys(moduleActions) + for (const id of ids) { + if (moduleActions[id]?.pending) { + acceptModule(id, flowStore) + } + } + } + + /** + * Reject all pending module actions (in reverse order for nested modules) + */ + function rejectAll(flowStore?: StateStore) { + const ids = Object.keys(moduleActions) + // Process in reverse to handle nested modules correctly + for (let i = ids.length - 1; i >= 0; i--) { + if (moduleActions[ids[i]]?.pending) { + rejectModule(ids[i], flowStore) + } + } + } + + /** + * Revert the entire flow to the snapshot + * @param flowStore - The flow store to update + * @param snapshot - Optional specific snapshot to revert to (defaults to beforeFlow) + */ + function revertToSnapshot(flowStore: StateStore, snapshot?: ExtendedOpenFlow) { + const targetSnapshot = snapshot ?? beforeFlow + if (!targetSnapshot) return + + flowStore.val = targetSnapshot + refreshStateStore(flowStore) + clearSnapshot() + } + + /** + * Set the DiffDrawer instance for showing module diffs + */ + function setDiffDrawer(drawer: DiffDrawer | undefined) { + diffDrawer = drawer + } + + /** + * Show diff for a specific module or Input schema + */ + function showModuleDiff(moduleId: string) { + if (!diffDrawer || !beforeFlow) return + + if (moduleId === SPECIAL_MODULE_IDS.INPUT) { + // Show input schema diff + diffDrawer.openDrawer() + diffDrawer.setDiff({ + mode: 'simple', + title: 'Flow Input Schema Diff', + original: { schema: beforeFlow.schema ?? {} }, + current: { schema: currentInputSchema ?? {} } + }) + } else { + // Show module diff + const beforeModule = getModuleFromFlow(moduleId, beforeFlow) + // Need to check failure_module and preprocessor_module for currentFlow as well + let afterModule: FlowModule | undefined = undefined + if (currentFlow) { + if (currentFlow.preprocessor_module?.id === moduleId) { + afterModule = currentFlow.preprocessor_module + } else if (currentFlow.failure_module?.id === moduleId) { + afterModule = currentFlow.failure_module + } else { + afterModule = dfs(moduleId, { value: currentFlow, summary: '' }, false)[0] + } + } + + if (beforeModule && afterModule) { + diffDrawer.openDrawer() + diffDrawer.setDiff({ + mode: 'simple', + title: `Module Diff: ${moduleId}`, + original: beforeModule, + current: afterModule + }) + } + } + } + + return { + // State accessors + get beforeFlow() { + return beforeFlow + }, + get currentFlow() { + return currentFlow + }, + get mergedFlow() { + return mergedFlow + }, + get moduleActions() { + return moduleActions + }, + get hasPendingChanges() { + return hasPendingChanges + }, + get currentInputSchema() { + return currentInputSchema + }, + get editModeEnabled() { + return editMode + }, + + // Snapshot management + setBeforeFlow, + setCurrentFlow, + setCurrentInputSchema, + setMarkRemovedAsShadowed, + setEditMode, + clearSnapshot, + + // Module actions management + setModuleActions, + + // Accept/reject operations + acceptModule, + rejectModule, + acceptAll, + rejectAll, + revertToSnapshot, + + // Diff drawer management + setDiffDrawer, + showModuleDiff + } +} diff --git a/frontend/src/lib/components/flows/flowExplorer.ts b/frontend/src/lib/components/flows/flowExplorer.ts index 66686f7bbb..2bde64e951 100644 --- a/frontend/src/lib/components/flows/flowExplorer.ts +++ b/frontend/src/lib/components/flows/flowExplorer.ts @@ -1,4 +1,5 @@ import type { FlowModule, InputTransform, OpenFlow } from '$lib/gen' +import { isFlowModuleTool } from './agentToolUtils' type ModuleBranches = FlowModule[][] @@ -9,6 +10,22 @@ export function getSubModules(flowModule: FlowModule): ModuleBranches { return flowModule.value.branches.map((branch) => branch.modules) } else if (flowModule.value.type == 'branchone') { return [...flowModule.value.branches.map((branch) => branch.modules), flowModule.value.default] + } else if (flowModule.value.type === 'aiagent') { + // Return AI agent tools as pseudo-FlowModules for searching + if (flowModule.value.tools) { + return [ + flowModule.value.tools + .filter(isFlowModuleTool) + .map( + (tool) => + ({ + id: tool.id, + value: tool.value, + summary: tool.summary + }) as FlowModule + ) + ] + } } return [] } diff --git a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts index 4e543b7fed..2a2b53d65d 100644 --- a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts +++ b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts @@ -165,7 +165,15 @@ export async function createBranchAll(id: string): Promise<[FlowModule, FlowModu export async function createAiAgent(id: string): Promise<[FlowModule, FlowModuleState]> { const aiAgentFlowModules: FlowModule = { id, - value: { type: 'aiagent', tools: [], input_transforms: {} } + value: { + type: 'aiagent', + tools: [], + input_transforms: { + provider: { type: 'static', value: undefined }, + output_type: { type: 'static', value: 'text' }, + user_message: { type: 'static', value: undefined } + } + } } const flowModuleState = await loadFlowModuleState(aiAgentFlowModules) diff --git a/frontend/src/lib/components/flows/header/FlowImportExportMenu.svelte b/frontend/src/lib/components/flows/header/FlowImportExportMenu.svelte index a9cbeecabb..657ce22fcd 100644 --- a/frontend/src/lib/components/flows/header/FlowImportExportMenu.svelte +++ b/frontend/src/lib/components/flows/header/FlowImportExportMenu.svelte @@ -5,7 +5,6 @@ import { getContext } from 'svelte' import type { FlowEditorContext } from '../types' import { cleanFlow } from '../utils.svelte' - import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte' interface Props { drawer: Drawer | undefined @@ -15,7 +14,7 @@ const { flowStore } = getContext('FlowEditorContext') - let flow = $derived(aiChatManager.flowAiChatHelpers?.getPreviewFlow() ?? flowStore.val) + let flow = $derived(flowStore.val) diff --git a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte index 64c4416d45..fc4018f6ee 100644 --- a/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte +++ b/frontend/src/lib/components/flows/header/FlowPreviewButtons.svelte @@ -8,7 +8,6 @@ import { getContext } from 'svelte' import type { FlowEditorContext } from '../types' import { Play } from 'lucide-svelte' - import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte' import type { GraphModuleState } from '$lib/components/graph' interface Props { @@ -94,8 +93,7 @@ 'Input', 'Trigger' ].includes(upToSelected) || - upToSelected?.includes('branch') || - aiChatManager.flowAiChatHelpers?.getModuleAction(upToSelected) === 'removed' + upToSelected?.includes('branch') ) }) diff --git a/frontend/src/lib/components/flows/map/DiffActionBar.svelte b/frontend/src/lib/components/flows/map/DiffActionBar.svelte new file mode 100644 index 0000000000..9d26c5b298 --- /dev/null +++ b/frontend/src/lib/components/flows/map/DiffActionBar.svelte @@ -0,0 +1,64 @@ + + +{#if moduleAction && diffManager} +
+ {#if moduleAction?.action === 'modified' && diffManager.beforeFlow} + + {/if} + {#if moduleAction?.pending} +
+ + +
+ {/if} +
+{/if} diff --git a/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte b/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte index c9d681da50..25d2591f73 100644 --- a/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte @@ -1,5 +1,6 @@ {#if flowStore.val?.value?.failure_module} - - {/if} - + + {:else} void + moduleAction: ModuleActionInfo | undefined retry?: boolean cache?: boolean earlyStop?: boolean @@ -95,7 +95,6 @@ selected = false, deletable = false, moduleAction = undefined, - onShowModuleDiff = undefined, retry = false, cache = false, earlyStop = false, @@ -127,12 +126,18 @@ maximizeSubflow = undefined }: Props = $props() - let colorClasses = $derived(getNodeColorClasses(nodeState, selected)) - - let pickableIds: Record | undefined = $state(undefined) + // Execution state takes priority over AI action colors + let effectiveState = $derived(nodeState ?? aiActionToNodeState(moduleAction?.action)) + let colorClasses = $derived(getNodeColorClasses(effectiveState, selected)) const flowEditorContext = getContext('FlowEditorContext') const flowInputsStore = flowEditorContext?.flowInputsStore + const flowStore = flowEditorContext?.flowStore + + const flowGraphContext = getGraphContext() + const diffManager = flowGraphContext?.diffManager + + let pickableIds: Record | undefined = $state(undefined) const dispatch = createEventDispatcher() @@ -186,8 +191,6 @@ const icon_render = $derived(icon) - const action = $derived(getAiModuleAction(id)) - let testRunDropdownOpen = $state(false) let outputPickerInner: OutputPickerInner | undefined = $state(undefined) @@ -252,9 +255,9 @@
{/if} -{#if deletable && id && flowEditorContext?.flowStore && outputPickerVisible} - {@const flowStore = flowEditorContext?.flowStore.val} - {@const mod = flowStore?.value ? dfsPreviousResults(id, flowStore, false)[0] : undefined} +{#if deletable && id && flowStore && outputPickerVisible} + {@const flowStoreVal = flowStore.val} + {@const mod = flowStoreVal?.value ? dfsPreviousResults(id, flowStoreVal, false)[0] : undefined} {#if mod && flowStateStore?.val?.[id]} -
(hover = false)} onpointerdown={stopPropagation(preventDefault((e) => dispatch('pointerdown', e)))} > - {#if deletable} - - {/if} - {#if moduleAction === 'modified' && onShowModuleDiff && id} -
- -
+ {#if id} + {/if}
-
+
- {#if deletable && !action} + {#if deletable} {#if maximizeSubflow !== undefined} {@render buttonMaximizeSubflow?.()} {/if} diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index c32ca18b18..968da1a4be 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -291,10 +291,15 @@ let graph: FlowGraphV2 | undefined = $state(undefined) let noteMode = $state(false) + let diffManager = $derived(getDiffManager()) export function isNodeVisible(nodeId: string): boolean { return graph?.isNodeVisible(nodeId) ?? false } + export function getDiffManager() { + return graph?.getDiffManager() + } + export function enableNotes(): void { graph?.enableNotes?.() } @@ -403,6 +408,7 @@ {toggleAiChat} {noteMode} {toggleNoteMode} + {diffManager} />
@@ -424,6 +430,8 @@ {noteMode} notes={flowStore.val.value.notes} preprocessorModule={flowStore.val.value?.preprocessor_module} + failureModule={flowStore.val.value?.failure_module} + currentInputSchema={flowStore.val.schema} {selectionManager} {workspace} editMode diff --git a/frontend/src/lib/components/flows/map/FlowStickyNode.svelte b/frontend/src/lib/components/flows/map/FlowStickyNode.svelte index b1821f98e3..ec0149a22c 100644 --- a/frontend/src/lib/components/flows/map/FlowStickyNode.svelte +++ b/frontend/src/lib/components/flows/map/FlowStickyNode.svelte @@ -1,5 +1,6 @@ {#snippet children({ hover })} - {#if editMode} - - {/if}
- import { FlowService, type FlowModule, type FlowNote, type Job } from '../../gen' + import { FlowService, type FlowModule, type FlowNote, type Job, type OpenFlow } from '../../gen' import { AI_OR_ASSET_NODE_TYPES, NODE, type GraphModuleState } from '.' import { getContext, onDestroy, onMount, tick, untrack, type Snippet } from 'svelte' + import { createFlowDiffManager } from '../flows/flowDiffManager.svelte' import { get, writable, type Writable } from 'svelte/store' import '@xyflow/svelte/dist/base.css' @@ -51,6 +52,7 @@ import type { TriggerContext } from '../triggers' import { workspaceStore } from '$lib/stores' import SubflowBound from './renderers/nodes/SubflowBound.svelte' + import DiffDrawer from '../DiffDrawer.svelte' import ViewportResizer from './ViewportResizer.svelte' import ViewportSynchronizer from './ViewportSynchronizer.svelte' import AssetNode, { computeAssetNodes } from './renderers/nodes/AssetNode.svelte' @@ -69,7 +71,7 @@ import type { ModulesTestStates } from '../modulesTest.svelte' import { deepEqual } from 'fast-equals' import type { AssetWithAltAccessType } from '../assets/lib' - import type { AIModuleAction } from '../copilot/chat/flow/core' + import type { ModuleActionInfo } from '$lib/components/flows/flowDiff' import { setGraphContext } from './graphContext' import { computeNoteNodes } from './noteUtils.svelte' import { Tooltip } from '../meltComponents' @@ -81,6 +83,9 @@ const triggerContext = getContext('TriggerContext') + // Create diffManager instance for this FlowGraphV2 + const diffManager = createFlowDiffManager() + let fullWidth = 0 let width = $state(0) @@ -96,8 +101,7 @@ notSelectable?: boolean flowModuleStates?: Record | undefined testModuleStates?: ModulesTestStates - moduleActions?: Record - inputSchemaModified?: boolean + moduleActions?: Record selectionManager?: SelectionManager path?: string | undefined newFlow?: boolean @@ -152,7 +156,6 @@ onCancelTestFlow?: () => void onOpenPreview?: () => void onHideJobStatus?: () => void - onShowModuleDiff?: (moduleId: string) => void flowHasChanged?: boolean exitNoteMode?: () => void onNotePositionUpdate?: (noteId: string, position: { x: number; y: number }) => void @@ -160,6 +163,10 @@ sharedViewport?: Viewport onViewportChange?: (viewport: Viewport, isUserInitiated: boolean) => void leftHeader?: Snippet + // Diff mode props + diffBeforeFlow?: OpenFlow + currentInputSchema?: Record + markRemovedAsShadowed?: boolean } let { @@ -182,7 +189,6 @@ flowModuleStates = undefined, testModuleStates = undefined, moduleActions = undefined, - inputSchemaModified = undefined, selectionManager: selectionManagerProp = undefined, path = undefined, newFlow = false, @@ -207,7 +213,6 @@ onCancelTestFlow = undefined, onOpenPreview = undefined, onHideJobStatus = undefined, - onShowModuleDiff = undefined, individualStepTests = false, flowJob = undefined, showJobStatus = false, @@ -221,6 +226,9 @@ sharedViewport = undefined, onViewportChange = undefined, leftHeader = undefined, + diffBeforeFlow = undefined, + currentInputSchema = undefined, + markRemovedAsShadowed = false, multiSelectEnabled = false }: Props = $props() @@ -273,7 +281,8 @@ showAssets, noteManager, clearFlowSelection, - yOffset + yOffset, + diffManager } as any) if (triggerContext && allowSimplifiedPoll) { @@ -294,6 +303,7 @@ if (isSimplifiable(modules)) { triggerContext?.simplifiedPoll?.set(undefined) } + diffManager.setDiffDrawer(undefined) }) function onModulesChange(modules: FlowModule[]) { @@ -454,7 +464,50 @@ } } - let moduleTracker = new ChangeTracker($state.snapshot(modules)) + // Validation: error if both diffBeforeFlow and moduleActions are provided + $effect(() => { + if (diffBeforeFlow && moduleActions) { + throw new Error('Cannot provide both diffBeforeFlow and moduleActions props to FlowGraphV2') + } + }) + + // Sync props to diffManager + $effect(() => { + const currentFlowValue = { + modules: modules, + failure_module: failureModule, + preprocessor_module: preprocessorModule + } + diffManager.setCurrentFlow(currentFlowValue) + diffManager.setCurrentInputSchema(currentInputSchema) + + // Handle diff mode setup + if (diffBeforeFlow) { + diffManager.setEditMode(editMode) + diffManager.setBeforeFlow(diffBeforeFlow) + diffManager.setMarkRemovedAsShadowed(markRemovedAsShadowed) + } else if (moduleActions) { + // Display-only mode: just set the module actions + diffManager.setModuleActions(moduleActions) + } + }) + + // Use diffManager state for rendering + let effectiveModuleActions = $derived(diffManager.moduleActions) + + // Use merged flow when in diff mode (includes removed modules), otherwise use raw modules + let effectiveModules = $derived(diffManager.mergedFlow?.modules ?? modules) + + let effectiveFailureModule = $derived(diffManager.mergedFlow?.failure_module ?? failureModule) + + let effectivePreprocessorModule = $derived( + diffManager.mergedFlow?.preprocessor_module ?? preprocessorModule + ) + + let canUseDiffDrawer = $derived(diffBeforeFlow || moduleActions || editMode) + + // Initialize moduleTracker with effectiveModules + let moduleTracker = $state(new ChangeTracker([])) let nodes = $state.raw([]) let edges = $state.raw([]) @@ -641,6 +694,7 @@ // centerViewport(width) // }) let yamlEditorDrawer: Drawer | undefined = $state(undefined) + let diffDrawer: DiffDrawer | undefined = $state(undefined) const flowGraphAssetsCtx = getContext('FlowGraphAssetContext') @@ -648,21 +702,26 @@ allowSimplifiedPoll && modules && untrack(() => onModulesChange(modules ?? [])) }) $effect(() => { - readFieldsRecursively(modules) - untrack(() => moduleTracker.track($state.snapshot(modules))) + readFieldsRecursively(effectiveModules) + untrack(() => moduleTracker.track($state.snapshot(effectiveModules))) + }) + + // Wire up the diff drawer to the diffManager + $effect(() => { + diffManager.setDiffDrawer(diffDrawer) }) let graph = $derived.by(() => { moduleTracker.counter + effectiveModuleActions return graphBuilder( - untrack(() => modules), + untrack(() => effectiveModules), { disableAi, insertable, flowModuleStates: untrack(() => flowModuleStates), testModuleStates: untrack(() => testModuleStates), - moduleActions: untrack(() => moduleActions), - inputSchemaModified: untrack(() => inputSchemaModified), + moduleActions: untrack(() => effectiveModuleActions), selectedId: untrack(() => selectedId), path, newFlow, @@ -677,11 +736,10 @@ suspendStatus, flowHasChanged, chatInputEnabled, - onShowModuleDiff: untrack(() => onShowModuleDiff), additionalAssetsMap: flowGraphAssetsCtx?.val.additionalAssetsMap }, - untrack(() => failureModule), - preprocessorModule, + untrack(() => effectiveFailureModule), + effectivePreprocessorModule, eventHandler, success, $useDataflow, @@ -805,6 +863,10 @@ viewportSynchronizer?.zoomOut() } + export function getDiffManager() { + return diffManager + } + export function enableNotes() { if (!showNotes) { showNotes = true @@ -817,6 +879,9 @@ {#if insertable} {/if} +{#if canUseDiffDrawer} + +{/if}
void + moduleAction?: ModuleActionInfo assets?: AssetWithAltAccessType[] | undefined } } @@ -150,8 +149,7 @@ export type ModuleN = { flowJob: Job | undefined isOwner: boolean assets: AssetWithAltAccessType[] | undefined - moduleAction: AIModuleAction | undefined - onShowModuleDiff?: (moduleId: string) => void + moduleAction: ModuleActionInfo | undefined } } @@ -370,8 +368,7 @@ export function graphBuilder( insertable: boolean flowModuleStates: Record | undefined testModuleStates: ModulesTestStates | undefined - moduleActions?: Record - inputSchemaModified?: boolean + moduleActions?: Record selectedId: string | undefined path: string | undefined newFlow: boolean @@ -386,7 +383,6 @@ export function graphBuilder( suspendStatus: Record flowHasChanged: boolean chatInputEnabled: boolean - onShowModuleDiff?: (moduleId: string) => void additionalAssetsMap?: Record }, failureModule: FlowModule | undefined, @@ -445,8 +441,7 @@ export function graphBuilder( isOwner: extra.isOwner, flowJob: extra.flowJob, assets: getFlowModuleAssets(module, extra.additionalAssetsMap), - moduleAction: extra.moduleActions?.[module.id], - onShowModuleDiff: extra.onShowModuleDiff + moduleAction: extra.moduleActions?.[module.id] }, type: 'module', selectable: true @@ -566,8 +561,7 @@ export function graphBuilder( showJobStatus: extra.showJobStatus, flowHasChanged: extra.flowHasChanged, chatInputEnabled: extra.chatInputEnabled, - inputSchemaModified: extra.inputSchemaModified, - onShowModuleDiff: extra.onShowModuleDiff, + moduleAction: extra.moduleActions?.['Input'], ...(inputAssets ? { assets: inputAssets } : {}) } } diff --git a/frontend/src/lib/components/graph/graphContext.ts b/frontend/src/lib/components/graph/graphContext.ts index 176fc5d3bf..c6a2342cb6 100644 --- a/frontend/src/lib/components/graph/graphContext.ts +++ b/frontend/src/lib/components/graph/graphContext.ts @@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte' import type { SelectionManager } from './selectionUtils.svelte' import type { NoteManager } from './noteManager.svelte' import type { Writable } from 'svelte/store' +import type { FlowDiffManager } from '../flows/flowDiffManager.svelte' export type GraphContext = { selectionManager: SelectionManager @@ -10,6 +11,7 @@ export type GraphContext = { noteManager?: NoteManager clearFlowSelection?: () => void yOffset?: number + diffManager: FlowDiffManager } const graphContextKey = 'FlowGraphContext' diff --git a/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte index 705a07b879..195f920d03 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/InputNode.svelte @@ -6,11 +6,11 @@ import InsertModulePopover from '$lib/components/flows/map/InsertModulePopover.svelte' import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte' + import DiffActionBar from '$lib/components/flows/map/DiffActionBar.svelte' import { schemaToObject } from '$lib/schema' import type { Schema } from '$lib/common' import type { FlowEditorContext } from '$lib/components/flows/types' - import { MessageSquare, DiffIcon } from 'lucide-svelte' - import { Button } from '$lib/components/common' + import { MessageSquare } from 'lucide-svelte' import { getGraphContext } from '../../graphContext' import FunnelCog from '$lib/components/icons/FunnelCog.svelte' @@ -20,10 +20,10 @@ let { data }: Props = $props() - const { selectionManager } = getGraphContext() + const { selectionManager, diffManager } = getGraphContext() - const { previewArgs, flowStore } = - getContext('FlowEditorContext') || {} + const flowEditorContext = getContext('FlowEditorContext') + const { previewArgs, flowStore } = flowEditorContext || {} let topFlowInput = $derived( flowStore?.val && previewArgs && flowStore?.val?.schema @@ -34,17 +34,12 @@ let inputLabel = $derived(data.chatInputEnabled ? 'Chat message' : 'Input') -{#if data.inputSchemaModified && data.onShowModuleDiff} -
- -
-{/if} + {#snippet children({ darkMode })} @@ -99,7 +94,7 @@ cache={data.cache} earlyStop={data.earlyStop} editMode={data.editMode} - action={data.inputSchemaModified ? 'modified' : undefined} + action={data.moduleAction?.action} onEditInput={data.eventHandlers.editInput} onTestFlow={() => { data.eventHandlers.testFlow() diff --git a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte index dd5d01ab0d..b90b49364f 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte @@ -56,7 +56,6 @@ insertable={data.insertable} editMode={data.editMode} moduleAction={data.moduleAction} - onShowModuleDiff={data.onShowModuleDiff} annotation={flowJobs && (data.module.value.type === 'forloopflow' || data.module.value.type === 'whileloopflow') ? 'Iteration: ' + diff --git a/frontend/src/lib/components/graph/util.ts b/frontend/src/lib/components/graph/util.ts index d1bb132515..92735df46c 100644 --- a/frontend/src/lib/components/graph/util.ts +++ b/frontend/src/lib/components/graph/util.ts @@ -1,4 +1,5 @@ import type { FlowStatusModule } from '$lib/gen' +import type { AIModuleAction } from '$lib/components/flows/flowDiff' export const NODE = { width: 275, @@ -18,7 +19,33 @@ export type FlowNodeColorClasses = { export const AI_OR_ASSET_NODE_TYPES = ['asset', 'assetsOverflowed', 'newAiTool', 'aiTool'] -export type FlowNodeState = FlowStatusModule['type'] | '_VirtualItem' | '_Skipped' | undefined +export type FlowNodeState = + | FlowStatusModule['type'] + | '_VirtualItem' + | '_Skipped' + | '_AIAdded' + | '_AIModified' + | '_AIRemoved' + | '_AIShadowed' + | undefined + +/** + * Convert AI module action to FlowNodeState + */ +export function aiActionToNodeState(action: AIModuleAction): FlowNodeState { + switch (action) { + case 'added': + return '_AIAdded' + case 'modified': + return '_AIModified' + case 'removed': + return '_AIRemoved' + case 'shadowed': + return '_AIShadowed' + default: + return undefined + } +} export function getNodeColorClasses(state: FlowNodeState, selected: boolean): FlowNodeColorClasses { let outlined = ' outline outline-1 active:outline active:outline-1' @@ -113,6 +140,63 @@ export function getNodeColorClasses(state: FlowNodeState, selected: boolean): Fl badge: 'bg-purple-200 text-purple-700' } }, + // AI Module Action states (distinct shades from execution states) + _AIAdded: { + selected: { + bg: 'bg-green-300 dark:bg-green-800', + outline: 'outline-green-600 dark:outline-green-500' + outlined, + text: 'text-green-900 dark:text-green-100', + badge: 'bg-green-200 text-green-800' + }, + notSelected: { + bg: 'bg-green-300 dark:bg-green-900', + outline: '', + text: 'text-green-800 dark:text-green-200', + badge: 'bg-green-300 text-green-800' + } + }, + _AIModified: { + selected: { + bg: 'bg-orange-300 dark:bg-orange-800', + outline: 'outline-orange-600' + outlined, + text: 'text-orange-900 dark:text-orange-100', + badge: 'bg-orange-200 text-orange-800' + }, + notSelected: { + bg: 'bg-orange-300 dark:bg-orange-900', + outline: '', + text: 'text-orange-800 dark:text-orange-200', + badge: 'bg-orange-300 text-orange-800' + } + }, + _AIRemoved: { + selected: { + bg: 'bg-red-300/50 dark:bg-red-800/50', + outline: 'outline-red-600' + outlined, + text: 'text-red-900 dark:text-red-100', + badge: 'bg-red-200 text-red-800' + }, + notSelected: { + bg: 'bg-red-300/50 dark:bg-red-900/50', + outline: '', + text: 'text-red-800 dark:text-red-200', + badge: 'bg-red-300 text-red-800' + } + }, + _AIShadowed: { + selected: { + bg: 'bg-gray-300/30 dark:bg-gray-600/30 opacity-50', + outline: 'outline-gray-500' + outlined, + text: 'text-gray-700 dark:text-gray-300', + badge: 'bg-gray-200 text-gray-700' + }, + notSelected: { + bg: 'bg-gray-300/30 dark:bg-gray-700/30 opacity-50', + outline: '', + text: 'text-gray-600 dark:text-gray-400', + badge: 'bg-gray-300 text-gray-700' + } + }, default: defaultStyle } as Record< NonNullable | 'default', diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 37375cac06..7a8323ae7a 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,3 +1,4 @@ +import { playwright } from '@vitest/browser-playwright' import { sveltekit } from '@sveltejs/kit/vite' import { readFileSync } from 'fs' import { fileURLToPath } from 'url' @@ -32,9 +33,7 @@ const config = { 'public.windmill.xyz' ], port: 3000, - cors: { - origin: '*' - }, + cors: { origin: '*' }, proxy: { '^/api/w/[^/]+/s3_proxy/.*': { target: process.env.REMOTE ?? 'https://app.windmill.dev/', @@ -44,6 +43,7 @@ const config = { proxy.on('proxyReq', (proxyReq, req, res) => { // Prevent collapsing slashes during URL normalization const originalPath = req.url + proxyReq.path = originalPath }) } @@ -74,13 +74,9 @@ const config = { } } }, - preview: { - port: 3001 - }, + preview: { port: 3001 }, plugins: [sveltekit(), ...(process.env.HTTPS === 'true' ? [mkcert()] : []), plugin], - define: { - __pkg__: version - }, + define: { __pkg__: version }, optimizeDeps: { include: ['highlight.js', 'highlight.js/lib/core', 'monaco-vim', 'monaco-editor-wrapper'], exclude: [ @@ -89,9 +85,7 @@ const config = { 'vscode' ] }, - worker: { - format: 'es' - }, + worker: { format: 'es' }, resolve: { alias: { path: 'path-browserify', @@ -100,7 +94,34 @@ const config = { }, dedupe: ['vscode', 'monaco-editor'] }, - assetsInclude: ['**/*.wasm'] + assetsInclude: ['**/*.wasm'], + test: { + expect: { requireAssertions: true }, + projects: [ + { + extends: './vite.config.js', + test: { + name: 'client', + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium', headless: true }] + }, + include: ['src/**/*.svelte.{test,spec}.{js,ts}'], + exclude: ['src/lib/server/**'] + } + }, + { + extends: './vite.config.js', + test: { + name: 'server', + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'], + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] + } + } + ] + } } export default config diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index d60b4ee4b9..e9e41de526 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -22,56 +22,75 @@ components: schemas: OpenFlow: type: object + description: Top-level flow definition containing metadata, configuration, and the flow structure properties: summary: type: string + description: Short description of what this flow does description: type: string + description: Detailed documentation for this flow value: $ref: "#/components/schemas/FlowValue" schema: type: object + description: JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe') required: - summary - value FlowValue: type: object + description: The flow structure containing modules and optional preprocessor/failure handlers properties: modules: type: array + description: Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch items: $ref: "#/components/schemas/FlowModule" failure_module: + description: Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types $ref: "#/components/schemas/FlowModule" preprocessor_module: + description: Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results $ref: "#/components/schemas/FlowModule" same_worker: type: boolean + description: If true, all steps run on the same worker for better performance concurrent_limit: type: number + description: Maximum number of concurrent executions of this flow concurrency_key: type: string + description: Expression to group concurrent executions (e.g., by user ID) concurrency_time_window_s: type: number + description: Time window in seconds for concurrent_limit debounce_delay_s: type: number + description: Delay in seconds to debounce flow executions debounce_key: type: string + description: Expression to group debounced executions skip_expr: type: string + description: JavaScript expression to conditionally skip the entire flow cache_ttl: type: number + description: Cache duration in seconds for flow results cache_ignore_s3_path: type: boolean flow_env: type: object + description: Environment variables available to all steps additionalProperties: type: string priority: type: number + description: Execution priority (higher numbers run first) early_return: type: string + description: JavaScript expression to return early from the flow chat_input_enabled: type: boolean description: Whether this flow accepts chat-style input @@ -85,28 +104,37 @@ components: Retry: type: object + description: Retry configuration for failed module executions properties: constant: type: object + description: Retry with constant delay between attempts properties: attempts: type: integer + description: Number of retry attempts seconds: type: integer + description: Seconds to wait between retries exponential: type: object + description: Retry with exponential backoff (delay doubles each time) properties: attempts: type: integer + description: Number of retry attempts multiplier: type: integer + description: Multiplier for exponential backoff seconds: type: integer minimum: 1 + description: Initial delay in seconds random_factor: type: integer minimum: 0 maximum: 100 + description: Random jitter percentage (0-100) to avoid thundering herd retry_if: $ref: "#/components/schemas/RetryIf" @@ -170,93 +198,127 @@ components: RetryIf: type: object + description: Conditional retry based on error or result properties: expr: type: string + description: JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables required: - expr StopAfterIf: type: object + description: Early termination condition for a module properties: skip_if_stopped: type: boolean + description: If true, following steps are skipped when this condition triggers expr: type: string + description: JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop error_message: type: string + description: Custom error message shown when stopping required: - expr FlowModule: type: object + description: A single step in a flow. Can be a script, subflow, loop, or branch properties: id: type: string + description: Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen) value: $ref: "#/components/schemas/FlowModuleValue" stop_after_if: + description: Early termination condition evaluated after this step completes $ref: "#/components/schemas/StopAfterIf" stop_after_all_iters_if: + description: For loops only - early termination condition evaluated after all iterations complete $ref: "#/components/schemas/StopAfterIf" skip_if: type: object + description: Conditionally skip this step based on previous results or flow inputs properties: expr: type: string + description: JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.' required: - expr sleep: + description: Delay before executing this step (in seconds or as expression) $ref: "#/components/schemas/InputTransform" cache_ttl: type: number + description: Cache duration in seconds for this step's results cache_ignore_s3_path: type: boolean timeout: + description: Maximum execution time in seconds (static value or expression) $ref: "#/components/schemas/InputTransform" delete_after_use: type: boolean + description: If true, this step's result is deleted after use to save memory summary: type: string + description: Short description of what this step does mock: type: object + description: Mock configuration for testing without executing the actual step properties: enabled: type: boolean - return_value: {} + description: If true, return mock value instead of executing + return_value: + description: Value to return when mocked suspend: type: object + description: Configuration for approval/resume steps that wait for user input properties: required_events: type: integer + description: Number of approvals required before continuing timeout: type: integer + description: Timeout in seconds before auto-continuing or canceling resume_form: type: object + description: Form schema for collecting input when resuming properties: schema: type: object + description: JSON Schema for the resume form user_auth_required: type: boolean + description: If true, only authenticated users can approve user_groups_required: + description: Expression or list of groups that can approve $ref: "#/components/schemas/InputTransform" self_approval_disabled: type: boolean + description: If true, the user who started the flow cannot approve hide_cancel: type: boolean + description: If true, hide the cancel button on the approval form continue_on_disapprove_timeout: type: boolean + description: If true, continue flow on timeout instead of canceling priority: type: number + description: Execution priority for this step (higher numbers run first) continue_on_error: type: boolean + description: If true, flow continues even if this step fails retry: + description: Retry configuration if this step fails $ref: "#/components/schemas/Retry" required: - value - id InputTransform: + description: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs oneOf: - $ref: "#/components/schemas/StaticTransform" - $ref: "#/components/schemas/JavascriptTransform" @@ -268,8 +330,10 @@ components: StaticTransform: type: object + description: Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource' properties: - value: {} + value: + description: The static value. For resources, use format '$res:path/to/resource' type: type: string enum: @@ -279,9 +343,11 @@ components: JavascriptTransform: type: object + description: JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value properties: expr: type: string + description: JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops) type: type: string enum: @@ -291,6 +357,7 @@ components: - type FlowModuleValue: + description: The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type oneOf: - $ref: "#/components/schemas/RawScript" - $ref: "#/components/schemas/PathScript" @@ -316,16 +383,20 @@ components: RawScript: type: object + description: Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms properties: # to be made required once migration is over input_transforms: type: object + description: Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments additionalProperties: $ref: "#/components/schemas/InputTransform" content: type: string + description: The script source code. Should export a 'main' function language: type: string + description: Programming language for this script enum: - deno - bun @@ -345,24 +416,32 @@ components: # TODO: Add missing languages path: type: string + description: Optional path for saving this script lock: type: string + description: Lock file content for dependencies type: type: string enum: - rawscript tag: type: string + description: Worker group tag for execution routing concurrent_limit: type: number + description: Maximum concurrent executions of this script concurrency_time_window_s: type: number + description: Time window for concurrent_limit custom_concurrency_key: type: string + description: Custom key for grouping concurrent executions is_trigger: type: boolean + description: If true, this script is a trigger that can start the flow assets: type: array + description: External resources this script accesses (S3 objects, resources, etc.) items: type: object required: @@ -371,8 +450,10 @@ components: properties: path: type: string + description: Path to the asset kind: type: string + description: Type of asset enum: - s3object - resource @@ -380,9 +461,11 @@ components: - datatable access_type: type: string + description: Access level for this asset enum: [r, w, rw] alt_access_type: type: string + description: Alternative access level enum: [r, w, rw] required: - type @@ -392,23 +475,29 @@ components: PathScript: type: object + description: Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code properties: input_transforms: type: object + description: Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments additionalProperties: $ref: "#/components/schemas/InputTransform" path: type: string + description: Path to the script in the workspace (e.g., 'f/scripts/send_email') hash: type: string + description: Optional specific version hash of the script to use type: type: string enum: - script tag_override: type: string + description: Override the script's default worker group tag is_trigger: type: boolean + description: If true, this script is a trigger that can start the flow required: - type - path @@ -416,13 +505,16 @@ components: PathFlow: type: object + description: Reference to an existing flow by path. Use this to call another flow as a subflow properties: input_transforms: type: object + description: Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments additionalProperties: $ref: "#/components/schemas/InputTransform" path: type: string + description: Path to the flow in the workspace (e.g., 'f/flows/process_user') type: type: string enum: @@ -434,22 +526,28 @@ components: ForloopFlow: type: object + description: Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations properties: modules: type: array + description: Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value' items: $ref: "#/components/schemas/FlowModule" iterator: + description: JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input' $ref: "#/components/schemas/InputTransform" skip_failures: type: boolean + description: If true, iteration failures don't stop the loop. Failed iterations return null type: type: string enum: - forloopflow parallel: type: boolean + description: If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency parallelism: + description: Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression $ref: "#/components/schemas/InputTransform" squash: type: boolean @@ -461,20 +559,25 @@ components: WhileloopFlow: type: object + description: Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination properties: modules: type: array + description: Steps to execute in each iteration. Use stop_after_if to control when the loop ends items: $ref: "#/components/schemas/FlowModule" skip_failures: type: boolean + description: If true, iteration failures don't stop the loop. Failed iterations return null type: type: string enum: - whileloopflow parallel: type: boolean + description: If true, iterations run concurrently (use with caution in while loops) parallelism: + description: Maximum number of concurrent iterations when parallel=true $ref: "#/components/schemas/InputTransform" squash: type: boolean @@ -485,18 +588,23 @@ components: BranchOne: type: object + description: Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes properties: branches: type: array + description: Array of branches to evaluate in order. The first branch with expr evaluating to true executes items: type: object properties: summary: type: string + description: Short description of this branch condition expr: type: string + description: JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins modules: type: array + description: Steps to execute if this branch's expr is true items: $ref: "#/components/schemas/FlowModule" required: @@ -504,9 +612,9 @@ components: - expr default: type: array + description: Steps to execute if no branch expressions match items: $ref: "#/components/schemas/FlowModule" - required: [modules] type: type: string enum: @@ -518,18 +626,23 @@ components: BranchAll: type: object + description: Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently properties: branches: type: array + description: Array of branches that all execute (either in parallel or sequentially) items: type: object properties: summary: type: string + description: Short description of this branch's purpose skip_failure: type: boolean + description: If true, failure in this branch doesn't fail the entire flow modules: type: array + description: Steps to execute in this branch items: $ref: "#/components/schemas/FlowModule" required: @@ -540,17 +653,21 @@ components: - branchall parallel: type: boolean + description: If true, all branches execute concurrently. If false, they execute sequentially required: - branches - type AgentTool: type: object + description: A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool properties: id: type: string + description: Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data') summary: type: string + description: Short description of what this tool does (shown to the AI) value: $ref: "#/components/schemas/ToolValue" required: @@ -558,6 +675,7 @@ components: - value ToolValue: + description: The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference oneOf: - $ref: "#/components/schemas/FlowModuleTool" - $ref: "#/components/schemas/McpToolValue" @@ -568,6 +686,7 @@ components: mcp: "#/components/schemas/McpToolValue" FlowModuleTool: + description: A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module allOf: - type: object properties: @@ -581,6 +700,7 @@ components: McpToolValue: type: object + description: Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers properties: tool_type: type: string @@ -588,12 +708,15 @@ components: - mcp resource_path: type: string + description: Path to the MCP resource/server configuration include_tools: type: array + description: Whitelist of specific tools to include from this MCP server items: type: string exclude_tools: type: array + description: Blacklist of tools to exclude from this MCP server items: type: string required: @@ -602,13 +725,39 @@ components: AiAgent: type: object + description: AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task properties: input_transforms: type: object - additionalProperties: - $ref: "#/components/schemas/InputTransform" + description: Input parameters for the AI agent mapped to their values + properties: + provider: + $ref: "#/components/schemas/InputTransform" + output_type: + $ref: "#/components/schemas/InputTransform" + user_message: + $ref: "#/components/schemas/InputTransform" + system_prompt: + $ref: "#/components/schemas/InputTransform" + streaming: + $ref: "#/components/schemas/InputTransform" + messages_context_length: + $ref: "#/components/schemas/InputTransform" + output_schema: + $ref: "#/components/schemas/InputTransform" + user_images: + $ref: "#/components/schemas/InputTransform" + max_completion_tokens: + $ref: "#/components/schemas/InputTransform" + temperature: + $ref: "#/components/schemas/InputTransform" + required: + - provider + - user_message + - output_type tools: type: array + description: Array of tools the agent can use. The agent decides which tools to call based on the task items: $ref: "#/components/schemas/AgentTool" type: @@ -617,6 +766,7 @@ components: - aiagent parallel: type: boolean + description: If true, the agent can execute multiple tool calls in parallel required: - tools - type @@ -624,6 +774,7 @@ components: Identity: type: object + description: Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder properties: type: type: string @@ -631,6 +782,7 @@ components: - identity flow: type: boolean + description: If true, marks this as a flow identity (special handling) required: - type