* setup first tests * better * variants * use openrouter + cleaning * write to files * cleaning * cleaning * inline scripts * more tests * cleaning * better * test all * few * better errors * cleaning * single set flow tool * handle malformed error * single tool * validate with zod * cleaning + check duplicates * exprtoset + aiaction over test * regen openflow * cleaning * better zod * recursive ignore of assets * cleaning * fix merge * rm * cleaning * comment
980 lines
32 KiB
TypeScript
980 lines
32 KiB
TypeScript
import type { FlowModule, FlowValue } from '$lib/gen'
|
|
import { dfs } from './dfs'
|
|
import { deepEqual } from 'fast-equals'
|
|
|
|
/** 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. Recursively normalizes nested modules
|
|
* within container types (branchone, branchall, forloopflow, whileloopflow, aiagent).
|
|
*/
|
|
function normalizeModuleForComparison(module: FlowModule): FlowModule {
|
|
// Deep clone to avoid mutating the original and to handle nested structures
|
|
const normalized = JSON.parse(JSON.stringify(module)) as FlowModule
|
|
|
|
// Helper to remove empty assets from a value object
|
|
function removeEmptyAssets(value: Record<string, unknown>): void {
|
|
if (Array.isArray(value.assets) && value.assets.length === 0) {
|
|
delete value.assets
|
|
}
|
|
}
|
|
|
|
if ('value' in normalized && normalized.value && typeof normalized.value === 'object') {
|
|
removeEmptyAssets(normalized.value as Record<string, unknown>)
|
|
|
|
// Recursively normalize nested modules based on type
|
|
const value = normalized.value
|
|
if (value.type === 'forloopflow' || value.type === 'whileloopflow') {
|
|
value.modules = value.modules.map((m) => normalizeModuleForComparison(m))
|
|
} else if (value.type === 'branchone') {
|
|
value.default = value.default.map((m) => normalizeModuleForComparison(m))
|
|
value.branches = value.branches.map((branch) => ({
|
|
...branch,
|
|
modules: branch.modules.map((m) => normalizeModuleForComparison(m))
|
|
}))
|
|
} else if (value.type === 'branchall') {
|
|
value.branches = value.branches.map((branch) => ({
|
|
...branch,
|
|
modules: branch.modules.map((m) => normalizeModuleForComparison(m))
|
|
}))
|
|
}
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
/**
|
|
* The complete diff result with action maps and merged flow
|
|
*/
|
|
export type FlowTimeline = {
|
|
/** Actions for modules in the before flow */
|
|
beforeActions: Record<string, ModuleActionInfo>
|
|
|
|
/** Actions for modules in the after flow (adjusted based on display mode) */
|
|
afterActions: Record<string, ModuleActionInfo>
|
|
|
|
/** The merged flow containing both after modules and removed modules properly nested */
|
|
mergedFlow: FlowValue
|
|
}
|
|
|
|
/**
|
|
* Computes the difference between two flow versions and returns a map of module IDs to their actions.
|
|
*
|
|
* When a module exists in both flows but has a different type, it's treated as removed + added
|
|
* rather than modified, since it's effectively a completely different module.
|
|
*
|
|
* @param beforeFlow - The original flow value
|
|
* @param afterFlow - The modified flow value
|
|
* @returns A record mapping module IDs to their diff result with separate before/after actions
|
|
*/
|
|
export function computeFlowModuleDiff(
|
|
beforeFlow: FlowValue,
|
|
afterFlow: FlowValue,
|
|
options: { markAsPending: boolean } = { markAsPending: false }
|
|
): {
|
|
beforeActions: Record<string, ModuleActionInfo>
|
|
afterActions: Record<string, ModuleActionInfo>
|
|
} {
|
|
const beforeActions: Record<string, ModuleActionInfo> = {}
|
|
const afterActions: Record<string, ModuleActionInfo> = {}
|
|
|
|
// 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([...beforeModulesWithLoc.keys(), ...afterModulesWithLoc.keys()])
|
|
|
|
for (const moduleId of allModuleIds) {
|
|
const beforeEntry = beforeModulesWithLoc.get(moduleId)
|
|
const afterEntry = afterModulesWithLoc.get(moduleId)
|
|
|
|
if (!beforeEntry && afterEntry) {
|
|
// Module exists in after but not before -> added
|
|
afterActions[moduleId] = { action: 'added', pending: options.markAsPending }
|
|
} else if (beforeEntry && !afterEntry) {
|
|
// Module exists in before but not after -> removed
|
|
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 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { beforeActions, afterActions }
|
|
}
|
|
|
|
/**
|
|
* Helper function to get all modules from a flow as a Map
|
|
*/
|
|
function getAllModulesMap(flow: FlowValue): Map<string, FlowModule> {
|
|
const moduleMap = new Map<string, FlowModule>()
|
|
|
|
// Get all regular modules
|
|
const allModules = dfs(flow.modules ?? [], (m) => m)
|
|
for (const module of allModules) {
|
|
if (module?.id) {
|
|
moduleMap.set(module.id, module)
|
|
}
|
|
}
|
|
|
|
// Add failure module if it exists
|
|
if (flow.failure_module?.id) {
|
|
moduleMap.set(flow.failure_module.id, flow.failure_module)
|
|
}
|
|
|
|
// Add preprocessor module if it exists
|
|
if (flow.preprocessor_module?.id) {
|
|
moduleMap.set(flow.preprocessor_module.id, flow.preprocessor_module)
|
|
}
|
|
|
|
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<string, ModuleWithLocation> {
|
|
const result = new Map<string, ModuleWithLocation>()
|
|
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
|
|
*/
|
|
export type ModuleParentLocation =
|
|
| { type: 'root'; index: number }
|
|
| { type: 'forloop' | 'whileloop'; parentId: string; index: number }
|
|
| { type: 'branchone-default'; parentId: string; index: number }
|
|
| { type: 'branchone-branch'; parentId: string; branchIndex: number; index: number }
|
|
| { type: 'branchall-branch'; parentId: string; branchIndex: number; index: number }
|
|
| { type: 'aiagent'; parentId: string; index: number }
|
|
| { type: 'failure'; index: -1 }
|
|
| { type: 'preprocessor'; index: -1 }
|
|
|
|
/**
|
|
* Finds the parent location of a module in a flow
|
|
*/
|
|
export function findModuleParent(flow: FlowValue, moduleId: string): ModuleParentLocation | null {
|
|
// Check special modules
|
|
if (flow.failure_module?.id === moduleId) {
|
|
return { type: 'failure', index: -1 }
|
|
}
|
|
if (flow.preprocessor_module?.id === moduleId) {
|
|
return { type: 'preprocessor', index: -1 }
|
|
}
|
|
|
|
// Check root level
|
|
const rootIndex = flow.modules?.findIndex((m) => m.id === moduleId)
|
|
if (rootIndex !== undefined && rootIndex >= 0) {
|
|
return { type: 'root', index: rootIndex }
|
|
}
|
|
|
|
// Recursively search nested modules
|
|
function searchInModules(modules: FlowModule[]): ModuleParentLocation | null {
|
|
for (const module of modules) {
|
|
// Check forloopflow
|
|
if (module.value.type === 'forloopflow') {
|
|
const index = module.value.modules.findIndex((m) => m.id === moduleId)
|
|
if (index >= 0) {
|
|
return { type: 'forloop', parentId: module.id, index }
|
|
}
|
|
const nested = searchInModules(module.value.modules)
|
|
if (nested) return nested
|
|
}
|
|
|
|
// Check whileloopflow
|
|
if (module.value.type === 'whileloopflow') {
|
|
const index = module.value.modules.findIndex((m) => m.id === moduleId)
|
|
if (index >= 0) {
|
|
return { type: 'whileloop', parentId: module.id, index }
|
|
}
|
|
const nested = searchInModules(module.value.modules)
|
|
if (nested) return nested
|
|
}
|
|
|
|
// Check branchone
|
|
if (module.value.type === 'branchone') {
|
|
// Check default branch
|
|
const defaultIndex = module.value.default.findIndex((m) => m.id === moduleId)
|
|
if (defaultIndex >= 0) {
|
|
return { type: 'branchone-default', parentId: module.id, index: defaultIndex }
|
|
}
|
|
const nestedDefault = searchInModules(module.value.default)
|
|
if (nestedDefault) return nestedDefault
|
|
|
|
// Check other branches
|
|
for (let branchIndex = 0; branchIndex < module.value.branches.length; branchIndex++) {
|
|
const branch = module.value.branches[branchIndex]
|
|
const index = branch.modules.findIndex((m) => m.id === moduleId)
|
|
if (index >= 0) {
|
|
return { type: 'branchone-branch', parentId: module.id, branchIndex, index }
|
|
}
|
|
const nested = searchInModules(branch.modules)
|
|
if (nested) return nested
|
|
}
|
|
}
|
|
|
|
// Check branchall
|
|
if (module.value.type === 'branchall') {
|
|
for (let branchIndex = 0; branchIndex < module.value.branches.length; branchIndex++) {
|
|
const branch = module.value.branches[branchIndex]
|
|
const index = branch.modules.findIndex((m) => m.id === moduleId)
|
|
if (index >= 0) {
|
|
return { type: 'branchall-branch', parentId: module.id, branchIndex, index }
|
|
}
|
|
const nested = searchInModules(branch.modules)
|
|
if (nested) return nested
|
|
}
|
|
}
|
|
|
|
// Check aiagent
|
|
if (module.value.type === 'aiagent' && module.value.tools) {
|
|
const index = (module.value.tools as FlowModule[]).findIndex((m) => m.id === moduleId)
|
|
if (index >= 0) {
|
|
return { type: 'aiagent', parentId: module.id, index }
|
|
}
|
|
const nested = searchInModules(module.value.tools as FlowModule[])
|
|
if (nested) return nested
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
return searchInModules(flow.modules ?? [])
|
|
}
|
|
|
|
/**
|
|
* Deep clones a module to avoid mutation
|
|
*/
|
|
function cloneModule(module: FlowModule): FlowModule {
|
|
return JSON.parse(JSON.stringify(module))
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
/**
|
|
* Collects all module IDs from a flow structure recursively
|
|
*/
|
|
function getAllModuleIds(flow: FlowValue): Set<string> {
|
|
const ids = new Set<string>()
|
|
|
|
function collectFromModules(modules: FlowModule[]): void {
|
|
for (const module of modules) {
|
|
if (module.id) {
|
|
ids.add(module.id)
|
|
}
|
|
|
|
// Recursively collect from nested modules
|
|
if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') {
|
|
collectFromModules(module.value.modules)
|
|
} else if (module.value.type === 'branchone') {
|
|
collectFromModules(module.value.default)
|
|
for (const branch of module.value.branches) {
|
|
collectFromModules(branch.modules)
|
|
}
|
|
} else if (module.value.type === 'branchall') {
|
|
for (const branch of module.value.branches) {
|
|
collectFromModules(branch.modules)
|
|
}
|
|
} else if (module.value.type === 'aiagent' && module.value.tools) {
|
|
collectFromModules(module.value.tools as FlowModule[])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect from root modules
|
|
if (flow.modules) {
|
|
collectFromModules(flow.modules)
|
|
}
|
|
|
|
// Collect from special modules
|
|
if (flow.failure_module?.id) {
|
|
ids.add(flow.failure_module.id)
|
|
}
|
|
if (flow.preprocessor_module?.id) {
|
|
ids.add(flow.preprocessor_module.id)
|
|
}
|
|
|
|
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<string>()
|
|
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<string, ModuleActionInfo>
|
|
): 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.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 = beforeModulesMap.get(removedId)
|
|
if (!beforeModule) continue
|
|
|
|
const parentLocation = findModuleParent(beforeFlow, removedId)
|
|
if (!parentLocation) continue
|
|
|
|
// Skip if parent is also removed - the module will be inserted as part of its parent
|
|
// This prevents duplicates when removing container modules with nested children
|
|
if (
|
|
parentLocation.type !== 'root' &&
|
|
parentLocation.type !== 'failure' &&
|
|
parentLocation.type !== 'preprocessor'
|
|
) {
|
|
if (removedModulesSet.has(parentLocation.parentId)) {
|
|
// Parent is also removed, skip this module
|
|
continue
|
|
}
|
|
}
|
|
|
|
let clonedModule = cloneModule(beforeModule)
|
|
|
|
// 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 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
|
|
} else if (parentLocation.type === 'preprocessor') {
|
|
merged.preprocessor_module = clonedModule
|
|
} else if (parentLocation.type === 'root') {
|
|
// Find the best position to insert in root modules
|
|
const insertIndex = findBestInsertPosition(
|
|
merged.modules ?? [],
|
|
beforeFlow.modules ?? [],
|
|
parentLocation.index,
|
|
removedId
|
|
)
|
|
if (!merged.modules) merged.modules = []
|
|
merged.modules.splice(insertIndex, 0, clonedModule)
|
|
} else {
|
|
// Find the parent module in merged flow and insert into it
|
|
insertIntoNestedParent(merged, parentLocation, clonedModule, beforeFlow)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
/**
|
|
* Finds the best position to insert a removed module in a module array
|
|
*/
|
|
function findBestInsertPosition(
|
|
targetModules: FlowModule[],
|
|
beforeModules: FlowModule[],
|
|
originalIndex: number,
|
|
removedId: string
|
|
): number {
|
|
// Look for anchors (modules that exist in both before and after)
|
|
// Try to find previous anchor
|
|
for (let i = originalIndex - 1; i >= 0; i--) {
|
|
const anchorId = beforeModules[i]?.id
|
|
const anchorIndex = targetModules.findIndex((m) => m.id === anchorId)
|
|
if (anchorIndex >= 0) {
|
|
return anchorIndex + 1
|
|
}
|
|
}
|
|
|
|
// Try to find next anchor
|
|
for (let i = originalIndex + 1; i < beforeModules.length; i++) {
|
|
const anchorId = beforeModules[i]?.id
|
|
const anchorIndex = targetModules.findIndex((m) => m.id === anchorId)
|
|
if (anchorIndex >= 0) {
|
|
return anchorIndex
|
|
}
|
|
}
|
|
|
|
// No anchors found, append to end
|
|
return targetModules.length
|
|
}
|
|
|
|
/**
|
|
* Inserts a removed module into its nested parent in the merged flow
|
|
*/
|
|
function insertIntoNestedParent(
|
|
merged: FlowValue,
|
|
parentLocation: ModuleParentLocation,
|
|
moduleToInsert: FlowModule,
|
|
beforeFlow: FlowValue
|
|
): void {
|
|
if (
|
|
parentLocation.type === 'root' ||
|
|
parentLocation.type === 'failure' ||
|
|
parentLocation.type === 'preprocessor'
|
|
) {
|
|
return
|
|
}
|
|
|
|
// Find the parent module in merged flow
|
|
const parentModule = findModuleById(merged, parentLocation.parentId)
|
|
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) {
|
|
console.warn('Before parent module not found', parentLocation)
|
|
return
|
|
}
|
|
|
|
// Insert based on type
|
|
if (parentLocation.type === 'forloop' && parentModule.value.type === 'forloopflow') {
|
|
const beforeModules = (beforeParent.value as any).modules ?? []
|
|
const insertIndex = findBestInsertPosition(
|
|
parentModule.value.modules,
|
|
beforeModules,
|
|
parentLocation.index,
|
|
moduleToInsert.id
|
|
)
|
|
parentModule.value.modules.splice(insertIndex, 0, moduleToInsert)
|
|
} else if (parentLocation.type === 'whileloop' && parentModule.value.type === 'whileloopflow') {
|
|
const beforeModules = (beforeParent.value as any).modules ?? []
|
|
const insertIndex = findBestInsertPosition(
|
|
parentModule.value.modules,
|
|
beforeModules,
|
|
parentLocation.index,
|
|
moduleToInsert.id
|
|
)
|
|
parentModule.value.modules.splice(insertIndex, 0, moduleToInsert)
|
|
} else if (
|
|
parentLocation.type === 'branchone-default' &&
|
|
parentModule.value.type === 'branchone'
|
|
) {
|
|
const beforeModules = (beforeParent.value as any).default ?? []
|
|
const insertIndex = findBestInsertPosition(
|
|
parentModule.value.default,
|
|
beforeModules,
|
|
parentLocation.index,
|
|
moduleToInsert.id
|
|
)
|
|
parentModule.value.default.splice(insertIndex, 0, moduleToInsert)
|
|
} else if (
|
|
parentLocation.type === 'branchone-branch' &&
|
|
parentModule.value.type === 'branchone'
|
|
) {
|
|
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 beforeModules = beforeBranch?.modules ?? []
|
|
const insertIndex = findBestInsertPosition(
|
|
branch.modules,
|
|
beforeModules,
|
|
parentLocation.index,
|
|
moduleToInsert.id
|
|
)
|
|
branch.modules.splice(insertIndex, 0, moduleToInsert)
|
|
}
|
|
} else if (
|
|
parentLocation.type === 'branchall-branch' &&
|
|
parentModule.value.type === 'branchall'
|
|
) {
|
|
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 beforeModules = beforeBranch?.modules ?? []
|
|
const insertIndex = findBestInsertPosition(
|
|
branch.modules,
|
|
beforeModules,
|
|
parentLocation.index,
|
|
moduleToInsert.id
|
|
)
|
|
branch.modules.splice(insertIndex, 0, moduleToInsert)
|
|
}
|
|
} else if (parentLocation.type === 'aiagent' && parentModule.value.type === 'aiagent') {
|
|
const tools = (parentModule.value.tools as FlowModule[]) ?? []
|
|
const beforeTools = ((beforeParent.value as any).tools as FlowModule[]) ?? []
|
|
const insertIndex = findBestInsertPosition(
|
|
tools,
|
|
beforeTools,
|
|
parentLocation.index,
|
|
moduleToInsert.id
|
|
)
|
|
tools.splice(insertIndex, 0, moduleToInsert)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds a module by ID anywhere in the flow
|
|
*/
|
|
function findModuleById(flow: FlowValue, moduleId: string): FlowModule | null {
|
|
const moduleMap = getAllModulesMap(flow)
|
|
return moduleMap.get(moduleId) ?? null
|
|
}
|
|
|
|
/**
|
|
* Adjusts the after actions based on display mode and adds entries for prefixed IDs
|
|
*/
|
|
function adjustActionsForDisplay(
|
|
afterActions: Record<string, ModuleActionInfo>,
|
|
beforeActions: Record<string, ModuleActionInfo>,
|
|
markRemovedAsShadowed: boolean,
|
|
mergedFlow: FlowValue
|
|
): Record<string, ModuleActionInfo> {
|
|
const adjusted: Record<string, ModuleActionInfo> = {}
|
|
|
|
// Copy all existing actions
|
|
for (const [id, action] of Object.entries(afterActions)) {
|
|
if (!markRemovedAsShadowed && action.action === 'shadowed') {
|
|
// In unified mode, change 'shadowed' to 'removed' for proper coloring
|
|
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 the duplicate prefix prepended to their ID
|
|
const allMergedIds = getAllModuleIds(mergedFlow)
|
|
for (const id of allMergedIds) {
|
|
if (id.startsWith(DUPLICATE_MODULE_PREFIX) && !adjusted[id]) {
|
|
// This is a prefixed ID for a module that was removed
|
|
const originalId = id.substring(DUPLICATE_MODULE_PREFIX.length)
|
|
// Check beforeActions to see if this module was removed
|
|
if (beforeActions[originalId]?.action === 'removed') {
|
|
adjusted[id] = {
|
|
action: markRemovedAsShadowed ? 'shadowed' : 'removed',
|
|
pending: beforeActions[originalId].pending
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return adjusted
|
|
}
|
|
|
|
/**
|
|
* Builds the complete flow diff result with action maps and merged flow.
|
|
* The merged flow contains all modules from afterFlow plus removed modules from
|
|
* beforeFlow properly nested in their original locations.
|
|
*
|
|
* @param beforeFlow - The original flow value
|
|
* @param afterFlow - The modified flow value
|
|
* @param options - Display options
|
|
* @returns Complete diff result with beforeActions, afterActions, and mergedFlow
|
|
*/
|
|
export function buildFlowTimeline(
|
|
beforeFlow: FlowValue,
|
|
afterFlow: FlowValue,
|
|
options: { markRemovedAsShadowed: boolean; markAsPending: boolean } = {
|
|
markRemovedAsShadowed: false,
|
|
markAsPending: false
|
|
}
|
|
): FlowTimeline {
|
|
// Compute the diff between the two flows
|
|
const { beforeActions, afterActions } = computeFlowModuleDiff(beforeFlow, afterFlow, {
|
|
markAsPending: options.markAsPending
|
|
})
|
|
|
|
// Reconstruct merged flow with removed modules properly nested
|
|
const mergedFlow = reconstructMergedFlow(afterFlow, beforeFlow, beforeActions)
|
|
|
|
// Adjust after actions based on display mode and add entries for prefixed IDs
|
|
const adjustedAfterActions = adjustActionsForDisplay(
|
|
afterActions,
|
|
beforeActions,
|
|
options.markRemovedAsShadowed,
|
|
mergedFlow
|
|
)
|
|
|
|
return {
|
|
beforeActions,
|
|
afterActions: adjustedAfterActions,
|
|
mergedFlow
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param beforeFlow - The original flow (can be OpenFlow or just have schema property)
|
|
* @param afterFlow - The modified flow (can be OpenFlow or just have schema property)
|
|
* @returns true if the schemas are different, false if identical
|
|
*/
|
|
export function hasInputSchemaChanged(
|
|
beforeFlow: { schema?: { [key: string]: unknown } } | undefined,
|
|
afterFlow: { schema?: { [key: string]: unknown } } | undefined
|
|
): boolean {
|
|
if (!beforeFlow || !afterFlow) {
|
|
return false
|
|
}
|
|
|
|
return !deepEqual(beforeFlow.schema, afterFlow.schema)
|
|
}
|