donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
247 lines • 10.6 kB
JavaScript
;
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