UNPKG

donobu

Version:

Create browser automations with an LLM agent and replay them as Playwright scripts.

247 lines 10.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.FlowDependencyAnalyzer = void 0; class FlowDependencyAnalyzer { /** * Analyzes a collection of flows and builds a dependency graph based on browser state references. */ static analyzeDependencies(flows) { const flowsMap = new Map(flows.map((flow) => [flow.id, flow])); const nameToIdMap = new Map(); // Build name-to-ID mapping for flows that have names // When multiple flows have the same name, prefer the one with the latest completedAt flows.forEach((flow) => { if (flow.name) { const existingFlowId = nameToIdMap.get(flow.name); if (!existingFlowId) { // First flow with this name nameToIdMap.set(flow.name, flow.id); } else { // Compare with existing flow to determine which one to keep const existingFlow = flowsMap.get(existingFlowId); const shouldReplace = this.shouldReplaceFlow(existingFlow, flow); if (shouldReplace) { nameToIdMap.set(flow.name, flow.id); } } } }); const dependencies = new Map(); const dependents = new Map(); // Initialize maps flows.forEach((flow) => { dependencies.set(flow.id, []); dependents.set(flow.id, []); }); // Analyze each flow's browser.initialState to determine dependencies flows.forEach((flow) => { const deps = this.extractDependencies(flow, nameToIdMap, flowsMap); dependencies.set(flow.id, deps); // Update dependents map deps.forEach((depId) => { const currentDependents = dependents.get(depId) || []; currentDependents.push(flow.id); dependents.set(depId, currentDependents); }); }); // Detect circular dependencies this.detectCircularDependencies(dependencies); // Find independent flows (no dependencies) const independentFlows = flows .filter((flow) => (dependencies.get(flow.id) || []).length === 0) .map((flow) => flow.id); // Calculate execution order using topological sort const executionOrder = this.calculateExecutionOrder(dependencies); return { flows: flowsMap, dependencies, dependents, independentFlows, executionOrder, }; } /** * Extracts dependencies for a single flow by analyzing its browser.initialState */ static extractDependencies(flow, nameToIdMap, flowsMap) { const initialState = flow.web?.browser?.initialState; if (!initialState) { return []; } switch (initialState.type) { case 'id': // Direct flow ID reference if (flowsMap.has(initialState.value)) { return [initialState.value]; } throw new Error(`Flow dependency not found: flow with ID "${initialState.value}" does not exist`); case 'name': // Flow name reference - resolve to ID const flowId = nameToIdMap.get(initialState.value); if (flowId) { return [flowId]; } throw new Error(`Flow dependency not found: flow with name "${initialState.value}" does not exist`); case 'testId': { // Resolve the test ID to its most recent successful flow within the // input set. Ordering matches DonobuFlowsManager.getBrowserStorageState, // which sorts SUCCESS flows by startedAt desc. const candidates = [...flowsMap.values()] .filter((f) => f.testId === initialState.value && f.state === 'SUCCESS') .sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0)); if (candidates.length > 0) { return [candidates[0].id]; } throw new Error(`Flow dependency not found: no successful flow exists for test "${initialState.value}"`); } case 'json': // Direct JSON state - no dependencies return []; default: throw new Error(`Unknown browser state reference type: ${initialState.type}`); } } /** * Determines if a new flow should replace an existing flow when they have the same name. * Prefers flows with later completedAt times, treating null completedAt as "never completed". */ static shouldReplaceFlow(existingFlow, newFlow) { // If both flows have completedAt values, prefer the later one if (existingFlow.completedAt !== null && newFlow.completedAt !== null) { return newFlow.completedAt > existingFlow.completedAt; } // If only the new flow has a completedAt value, prefer it if (existingFlow.completedAt === null && newFlow.completedAt !== null) { return true; } // If only the existing flow has a completedAt value, keep it if (existingFlow.completedAt !== null && newFlow.completedAt === null) { return false; } // If neither has completedAt, prefer the one with the later startedAt // (this handles flows that are still running or were interrupted) if (existingFlow.startedAt !== null && newFlow.startedAt !== null) { return newFlow.startedAt > existingFlow.startedAt; } // If only the new flow has startedAt, prefer it if (existingFlow.startedAt === null && newFlow.startedAt !== null) { return true; } // If only the existing flow has startedAt, keep it if (existingFlow.startedAt !== null && newFlow.startedAt === null) { return false; } // If we can't determine based on timestamps, keep the existing flow return false; } /** * Detects circular dependencies using DFS */ static detectCircularDependencies(dependencies) { const visiting = new Set(); const visited = new Set(); const visit = (flowId, path) => { if (visiting.has(flowId)) { const cycleStart = path.indexOf(flowId); const cycle = [...path.slice(cycleStart), flowId]; throw new Error(`Circular dependency detected: ${cycle.join(' -> ')}`); } if (visited.has(flowId)) { return; } visiting.add(flowId); const deps = dependencies.get(flowId) || []; deps.forEach((depId) => { visit(depId, [...path, flowId]); }); visiting.delete(flowId); visited.add(flowId); }; // Check each flow for cycles dependencies.forEach((_, flowId) => { if (!visited.has(flowId)) { visit(flowId, []); } }); } /** * Calculates execution order using topological sort with parallel execution support */ static calculateExecutionOrder(dependencies) { const executionOrder = []; const remainingFlows = new Set(dependencies.keys()); const completedFlows = new Set(); // Process flows in waves - each wave can execute in parallel while (remainingFlows.size > 0) { const currentWave = []; // Find flows that can execute now (all dependencies completed) for (const flowId of remainingFlows) { const deps = dependencies.get(flowId) || []; const canExecute = deps.every((depId) => completedFlows.has(depId)); if (canExecute) { currentWave.push(flowId); } } if (currentWave.length === 0) { // This should not happen if circular dependency detection worked correctly const remaining = Array.from(remainingFlows); throw new Error(`Unable to resolve execution order. Remaining flows: ${remaining.join(', ')}`); } // Add current wave to execution order executionOrder.push(currentWave); // Mark these flows as completed and remove from remaining currentWave.forEach((flowId) => { completedFlows.add(flowId); remainingFlows.delete(flowId); }); } return executionOrder; } /** * Validates that all flow dependencies can be resolved */ static validateDependencies(flows) { try { this.analyzeDependencies(flows); } catch (error) { throw new Error(`Flow dependency validation failed: ${error instanceof Error ? error.message : String(error)}`); } } /** * Gets a human-readable summary of the dependency graph */ static getDependencySummary(graph) { const lines = []; lines.push(`Flow Dependency Analysis:`); lines.push(`- Total flows: ${graph.flows.size}`); lines.push(`- Independent flows: ${graph.independentFlows.length}`); lines.push(`- Execution waves: ${graph.executionOrder.length}`); lines.push(''); lines.push('Execution Order:'); graph.executionOrder.forEach((wave, index) => { const waveNames = wave.map((flowId) => { const flow = graph.flows.get(flowId); return flow?.name || flowId; }); lines.push(` Wave ${index + 1}: ${waveNames.join(', ')}`); }); lines.push(''); lines.push('Dependencies:'); graph.dependencies.forEach((deps, flowId) => { if (deps.length > 0) { const flow = graph.flows.get(flowId); const flowName = flow?.name || flowId; const depNames = deps.map((depId) => { const depFlow = graph.flows.get(depId); return depFlow?.name || depId; }); lines.push(` ${flowName} depends on: ${depNames.join(', ')}`); } }); return lines.join('\n'); } } exports.FlowDependencyAnalyzer = FlowDependencyAnalyzer; //# sourceMappingURL=FlowDependencyAnalyzer.js.map