UNPKG

donobu

Version:

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

971 lines 46.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.DonobuFlowsManager = void 0; exports.distillAllowedEnvVariableNames = distillAllowedEnvVariableNames; exports.setupAllowedTools = setupAllowedTools; exports.prepareToolCallsForRerun = prepareToolCallsForRerun; const crypto_1 = require("crypto"); const fs = __importStar(require("fs/promises")); const os_1 = require("os"); const path = __importStar(require("path")); const CodeGenerator_1 = require("../codegen/CodeGenerator"); const ActiveFlowNotFoundException_1 = require("../exceptions/ActiveFlowNotFoundException"); const BrowserStateNotFoundException_1 = require("../exceptions/BrowserStateNotFoundException"); const CannotDeleteRunningFlowException_1 = require("../exceptions/CannotDeleteRunningFlowException"); const FlowIdCollisionException_1 = require("../exceptions/FlowIdCollisionException"); const FlowNotFoundException_1 = require("../exceptions/FlowNotFoundException"); const InvalidParamValueException_1 = require("../exceptions/InvalidParamValueException"); const TestNotFoundException_1 = require("../exceptions/TestNotFoundException"); const ToolRequiresGptException_1 = require("../exceptions/ToolRequiresGptException"); const UnknownToolException_1 = require("../exceptions/UnknownToolException"); const GptConfig_1 = require("../models/GptConfig"); const resolveTargetRuntime_1 = require("../targets/resolveTargetRuntime"); const CustomToolRunnerTool_1 = require("../tools/CustomToolRunnerTool"); const buildProvenance_1 = require("../utils/buildProvenance"); const displayName_1 = require("../utils/displayName"); const FlowLogBuffer_1 = require("../utils/FlowLogBuffer"); const Logger_1 = require("../utils/Logger"); const MiscUtils_1 = require("../utils/MiscUtils"); const TemplateInterpolator_1 = require("../utils/TemplateInterpolator"); const DonobuFlow_1 = require("./DonobuFlow"); const FlowCatalog_1 = require("./FlowCatalog"); const FlowRuntime_1 = require("./FlowRuntime"); const InteractionVisualizer_1 = require("./InteractionVisualizer"); const ToolManager_1 = require("./ToolManager"); class DonobuFlowsManager { constructor(deploymentEnvironment, gptClientFactory, gptConfigsManager, agentsManager, flowsPersistenceRegistry, envDataManager, controlPanelFactory, environ, toolRegistry, targetRuntimePlugins, testsPersistenceRegistry) { this.deploymentEnvironment = deploymentEnvironment; this.gptClientFactory = gptClientFactory; this.gptConfigsManager = gptConfigsManager; this.agentsManager = agentsManager; this.flowsPersistenceRegistry = flowsPersistenceRegistry; this.envDataManager = envDataManager; this.controlPanelFactory = controlPanelFactory; this.environ = environ; this.toolRegistry = toolRegistry; this.targetRuntimePlugins = targetRuntimePlugins; this.testsPersistenceRegistry = testsPersistenceRegistry; this.flowRuntime = new FlowRuntime_1.FlowRuntime(); this.flowCatalog = new FlowCatalog_1.FlowCatalog(this.flowsPersistenceRegistry, this.flowRuntime, this.deploymentEnvironment); } /** * Create a flow with the given parameters and invoke its `DonobuFlow#run` * method, adding it to list of active flows. * * If `flowParams.testId` is set, the new flow is persisted to the same * layer as the referenced test so the `flow_metadata.test_id` foreign * key holds. Otherwise the primary layer is used. * * @param gptClient If present, will use this as the associated GPT client for * this flow instead of instantiating a new one. If so, the * gptConfigNameOverride field will be ignored. * @param browserContextOverride If present, will use this as the browser * context instead of instantiating a new one. If so, most browser * related parameters are will be ignored. */ async createFlow(flowParams, gptClient, browserContextOverride) { if (flowParams.id) { await this.assertFlowIdAvailable(flowParams.id); } const flowId = flowParams.id ?? (0, crypto_1.randomUUID)(); const logBuffer = new FlowLogBuffer_1.FlowLogBuffer(); return Logger_1.loggingContext.run({ flowId, logBuffer }, async () => { const messageDuration = flowParams.defaultMessageDuration ?? DonobuFlowsManager.DEFAULT_MESSAGE_DURATION; const interactionVisualizer = new InteractionVisualizer_1.InteractionVisualizer(messageDuration); // Run GPT client resolution and target runtime in parallel since these are // the long-poles for when creating a flow. const [gptClientResult, targetRuntimeResult] = await Promise.allSettled([ gptClient ? Promise.resolve({ gptConfigName: null, agentName: null, gptClient: gptClient, }) : this.createGptClient(flowParams.gptConfigNameOverride ?? undefined), (0, resolveTargetRuntime_1.resolveTargetRuntime)({ flowParams, flowId, interactionVisualizer, controlPanelFactory: this.controlPanelFactory, environ: this.environ, browserContextOverride, getBrowserStorageState: (ref) => this.getBrowserStorageState(ref), }, this.targetRuntimePlugins), ]); // Careefully walk things back if things went off the rails. if (gptClientResult.status === 'rejected' || targetRuntimeResult.status === 'rejected') { if (targetRuntimeResult.status === 'fulfilled') { await targetRuntimeResult.value.destroy(); } throw gptClientResult.status === 'rejected' ? gptClientResult.reason : targetRuntimeResult.reason; } const gptClientData = gptClientResult.value; const targetRuntime = targetRuntimeResult.value; const initialRunMode = flowParams.initialRunMode ?? (gptClientData.gptClient ? 'AUTONOMOUS' : 'INSTRUCT'); // --- Generic validation (target-agnostic) --- await validateFlowParams(flowParams, gptClientData.gptClient, initialRunMode, this.toolRegistry); try { const allowedTools = await setupAllowedTools(flowParams, gptClientData !== null, targetRuntime.targetType, this.toolRegistry); const toolManager = new ToolManager_1.ToolManager(allowedTools); const toolCallsOnStart = flowParams.toolCallsOnStart?.length ? flowParams.toolCallsOnStart : targetRuntime.getInitialToolCalls(flowParams); let maxToolCalls = null; if (initialRunMode === 'AUTONOMOUS') { maxToolCalls = flowParams.maxToolCalls ?? DonobuFlowsManager.DEFAULT_MAX_TOOL_CALLS; } if (maxToolCalls !== null && maxToolCalls < 0) { throw new InvalidParamValueException_1.InvalidParamValueException('maxToolCalls', String(flowParams.maxToolCalls)); } const allowedEnvVarsByName = distillAllowedEnvVariableNames(flowParams.overallObjective, flowParams.envVars); const flowMetadata = { id: flowId, target: flowParams.target, metadataVersion: 1, createdWithDonobuVersion: MiscUtils_1.MiscUtils.DONOBU_VERSION, name: flowParams.name || null, envVars: allowedEnvVarsByName, runMode: initialRunMode, isControlPanelEnabled: flowParams.isControlPanelEnabled ?? true, gptConfigName: gptClientData.gptConfigName, hasGptConfigNameOverride: !gptClientData.agentName, customTools: flowParams.customTools ?? null, defaultMessageDuration: interactionVisualizer.defaultMessageDurationMillis, callbackUrl: flowParams.callbackUrl || null, overallObjective: flowParams.overallObjective ?? null, allowedTools: allowedTools.map((tool) => tool.name), resultJsonSchema: flowParams.resultJsonSchema || null, maxToolCalls: maxToolCalls, result: null, inputTokensUsed: 0, completionTokensUsed: 0, startedAt: null, completedAt: null, state: 'UNSTARTED', nextState: null, videoDisabled: flowParams.videoDisabled, testId: flowParams.testId ?? null, // Target-specific fields (browser, targetWebsite, isControlPanelEnabled, etc.) ...targetRuntime.getMetadataFields(), provenance: (0, buildProvenance_1.buildProvenance)('DONOBU_STUDIO'), }; const flowsPersistence = await this.resolveLayerForFlowCreate(flowParams.testId ?? null); const envData = await this.envDataManager.getByNames(flowMetadata.envVars ?? []); const donobuFlow = new DonobuFlow_1.DonobuFlow(this, envData, flowsPersistence, gptClientData.gptClient, toolManager, interactionVisualizer, toolCallsOnStart, [], [], targetRuntime.inspector, flowMetadata, targetRuntime.controlPanel); await flowsPersistence.setFlowMetadata(flowMetadata); const flowHandle = { donobuFlow: donobuFlow, job: Promise.resolve(null), // Temporary placeholder. logBuffer, }; // Register BEFORE starting the job so cleanup always runs. this.flowRuntime.register(flowHandle); flowHandle.job = donobuFlow.run().finally(async () => { await targetRuntime.destroy(); if (targetRuntime.videoDir) { try { await setFlowVideo(donobuFlow.metadata.id, targetRuntime.videoDir, flowsPersistence); } catch (error) { Logger_1.appLogger.error('Failed to save flow video:', error); } await removeTempDirectoryForFlow(donobuFlow.metadata.id); } try { const snapshot = logBuffer.snapshot(); await flowsPersistence.setFlowFile(donobuFlow.metadata.id, 'logs.json', Buffer.from(JSON.stringify(snapshot))); } catch (error) { Logger_1.appLogger.error('Failed to persist flow logs:', error); } // Remove from runtime AFTER all cleanup (including log flush) so // that getFlowLogs() can still serve from the live buffer until // the persisted snapshot is written. try { this.flowRuntime.remove(donobuFlow.metadata.id); } catch (error) { Logger_1.appLogger.error('Failed to delete active flow:', error); } }); return flowHandle; } catch (error) { await targetRuntime.destroy(); if (targetRuntime.videoDir) { await removeTempDirectoryForFlow(flowId); } throw error; } }); } async getFlowLogs(flowId) { // If the flow is actively running, read from the live in-memory buffer. const activeHandle = this.isLocallyRunning() ? this.flowRuntime.get(flowId) : null; if (activeHandle) { return activeHandle.logBuffer.snapshot(); } // Otherwise, read the persisted snapshot. const persistence = await this.flowsPersistenceRegistry.get(); const file = await persistence.getFlowFile(flowId, 'logs.json'); if (file) { return JSON.parse(file.toString('utf8')); } // Flow exists but has no persisted logs (e.g. ran before this feature). return { entries: [], evicted: { donobu: 0, browser: 0, network: 0 } }; } async renameFlow(flowId, name) { validateFlowName(name); const activeFlowHandle = this.isLocallyRunning() ? this.flowRuntime.get(flowId) : null; // If the flow is active, we need to rename it at both its in-memory location // and its persisted location. if (activeFlowHandle) { activeFlowHandle.donobuFlow.metadata.name = name; } for (const persistence of await this.flowsPersistenceRegistry.getAll()) { try { const metadata = await persistence.getFlowMetadataById(flowId); metadata.name = name; await persistence.setFlowMetadata(metadata); return metadata; } catch (error) { if (!(error instanceof FlowNotFoundException_1.FlowNotFoundException)) { throw error; } } } throw FlowNotFoundException_1.FlowNotFoundException.forId(flowId); } /** * Converts a prior flow's tool calls into a list of tool calls to invoke when * starting a new flow as a rerun (i.e. without agentic decisioning). * * @param priorFlowMetadata The metadata of the flow to prepare as a rerun. * @param options The code generation options to use for the rerun. * * @returns A list of tool calls to invoke when starting the flow. */ async getToolCallsForRerun(priorFlowMetadata, options) { const originalToolCalls = await this.getToolCalls(priorFlowMetadata.id); // Delegate tool call preparation to the target runtime plugin so that // rerun logic is fully target-agnostic — it dispatches per tool via // the tool's own `prepareForRerun` override. return prepareToolCallsForRerun(originalToolCalls, options, this.toolRegistry); } /** * Loads the given flow by ID and returns a `CreateDonobuFlow` object that can be passed to `createFlow` * to execute the flow as a rerun (i.e. without agentic decisioning). * * @param flowId The ID of the flow to prepare as a rerun. * @returns Parameters that can be passed to createFlow to execute the flow as a rerun. */ async getFlowAsRerun(flowId, options) { const priorFlowMetadata = await this.getFlowById(flowId); const toolCallsOnStart = await this.getToolCallsForRerun(priorFlowMetadata, options); return this.getFlowFromConfigAndToolCalls((0, displayName_1.getDisplayName)(priorFlowMetadata, 'Untitled Flow'), 'DETERMINISTIC', priorFlowMetadata, toolCallsOnStart); } /** * Takes a RunConfig object, or anything derived from it (FlowMetadata, * TestMetadata), plus some additional parameters, and returns a * CreateDonobuFlow object that can be passed to `createFlow` to execute the * flow. * * @param name The name for the new flow * @param runMode The run mode to be used for the flow * @param config The RunConfig object * @param toolCallsOnStart An ordered series of tool calls to invoke when * starting the flow * * @returns A CreateDonobuFlow object that can be passed to createFlow to * execute the flow. */ getFlowFromConfigAndToolCalls(name, runMode, config, toolCallsOnStart) { // Build target-specific config from the config object. const targetConfig = {}; const targetKey = config.target; if (targetKey && targetKey in config) { targetConfig[targetKey] = config[targetKey]; } return { target: config.target, ...targetConfig, overallObjective: config.overallObjective ?? undefined, name, callbackUrl: config.callbackUrl ?? undefined, customTools: config.customTools ?? undefined, maxToolCalls: config.maxToolCalls, defaultMessageDuration: runMode === 'DETERMINISTIC' ? 1000 : undefined, initialRunMode: runMode, isControlPanelEnabled: true, allowedTools: config.allowedTools ?? [], toolCallsOnStart, envVars: config.envVars ?? undefined, resultJsonSchema: config.resultJsonSchema ?? undefined, videoDisabled: config.videoDisabled, }; } /** Add a proposed tool call the tool call queue for the given flow by ID. */ async proposeToolCall(flowId, toolName, parameters) { const activeFlowHandle = this.isLocallyRunning() ? this.flowRuntime.get(flowId) : null; if (!activeFlowHandle) { throw new ActiveFlowNotFoundException_1.ActiveFlowNotFoundException(flowId); } const tool = this.toolRegistry.allTools().find((t) => t.name === toolName); if (!tool) { throw new UnknownToolException_1.UnknownToolException(toolName); } activeFlowHandle.donobuFlow.proposedToolCalls.push({ name: tool.name, parameters: parameters, }); } /** * If the application is running in a non-hosted context, returns a direct, * raw, `DonobuFlow` object by ID. If there is no flow in an active state * with the given ID, or the application is running on some far flung server, * then `ActiveFlowNotFoundException` is thrown. Mutations made to the * returned object will be reflected by the active flow, and vice versa. */ getActiveFlow(flowId) { if (!this.isLocallyRunning()) { throw new ActiveFlowNotFoundException_1.ActiveFlowNotFoundException(flowId); } const activeFlowHandle = this.flowRuntime.get(flowId); if (!activeFlowHandle) { throw new ActiveFlowNotFoundException_1.ActiveFlowNotFoundException(flowId); } return activeFlowHandle.donobuFlow; } /** * Get flows metadata across multiple persistence layers with pagination and filtering. */ async getFlows(query) { return this.flowCatalog.getFlows(query); } /** * Returns the metadata for the given flow by ID. If the flow is active, the * returned metadata object is shared with the underlying flow and changes * made to this metadata object will be visible to the flow. If the flow is * not active, a copy of the persisted metadata is returned. */ async getFlowById(flowId) { return this.flowCatalog.getFlowById(flowId); } /** * Returns the metadata for the given flow by name. */ async getFlowByName(flowName) { return this.flowCatalog.getFlowByName(flowName); } /** Returns all the tool calls made by the given flow by ID. */ async getToolCalls(flowId) { return this.flowCatalog.getToolCalls(flowId); } /** Returns all AI query records for the given flow by ID. */ async getAiQueries(flowId) { return this.flowCatalog.getAiQueries(flowId); } /** * Attempts to delete a flow by ID. If the flow is active, then * `CannotDeleteRunningFlowException` is thrown. If the flow is not active, * then the flow's persisted data is deleted. */ async deleteFlowById(flowId) { const activeFlow = this.isLocallyRunning() ? this.flowRuntime.get(flowId) : null; if (activeFlow) { throw new CannotDeleteRunningFlowException_1.CannotDeleteRunningFlowException(flowId); } await this.flowCatalog.deleteFlow(flowId); } /** * Attempts to cancel a flow by ID. If the flow is active, the flow is ended * with a state of `FAILED`. If the flow is not active, this method has no * effect. */ async cancelFlow(flowId) { const activeFlowHandle = this.isLocallyRunning() ? this.flowRuntime.get(flowId) : null; if (activeFlowHandle) { activeFlowHandle.donobuFlow.metadata.nextState = 'FAILED'; // If the target is still connected, cancel will cause the flow to fail // on its next iteration. For web flows, close the browser context. if (activeFlowHandle.donobuFlow.targetInspector.connected) { const target = activeFlowHandle.donobuFlow.targetInspector.target; if (target.type === 'web' && target.current) { await target.current.context().close(); } } return activeFlowHandle.donobuFlow.metadata; } return await this.getFlowById(flowId); } /** Creates a Node.js Microsoft Playwright script to replay the given flow. */ async getFlowAsPlaywrightScript(flowId, options) { const effectiveOptions = { areElementIdsVolatile: false, disableSelectorFailover: false, playwrightScriptVariant: 'ai', ...options, }; const flowMetadata = await this.getFlowById(flowId); // Do not generate code for non-successful tool calls. const originalToolCalls = (await this.getToolCalls(flowId)).filter((tc) => tc.outcome?.isSuccessful); const proposedToolCalls = await prepareToolCallsForRerun(originalToolCalls, effectiveOptions, this.toolRegistry); const scriptVariant = effectiveOptions.playwrightScriptVariant === 'classic' ? 'classic' : 'ai'; if (scriptVariant === 'classic') { return (0, CodeGenerator_1.getFlowAsPlaywrightScript)(flowMetadata, proposedToolCalls, effectiveOptions, this.toolRegistry); } return (0, CodeGenerator_1.getFlowAsAiPlaywrightScript)(flowMetadata, proposedToolCalls, effectiveOptions, this.toolRegistry); } /** * Generates a complete Playwright project structure for multiple flows with dependency management. * Automatically includes any missing dependencies to ensure a complete dependency graph. */ async getFlowsAsPlaywrightProject(flowIds, options) { // Only generate test files for the requested flows — not their // transitive dependencies. Dependencies are materialized as static // browser-state JSON files instead. const flowsWithToolCalls = await Promise.all(flowIds.map(async (flowId) => { const metadata = await this.getFlowById(flowId); const toolCalls = await this.getToolCalls(flowId); // Filter out unsuccessful tool calls and prepare them for replay. const successfulToolCalls = toolCalls.filter((tc) => tc.outcome?.isSuccessful); const proposedToolCalls = await prepareToolCallsForRerun(successfulToolCalls, options, this.toolRegistry); return { metadata, toolCalls: proposedToolCalls, }; })); // Resolve browser states for flows that have initialState references. // These get materialized as static JSON files in the generated project. const resolvedBrowserStates = new Map(); for (const { metadata } of flowsWithToolCalls) { const initialState = metadata.web?.browser?.initialState; if (initialState) { try { const browserState = await this.getBrowserStorageState(initialState); resolvedBrowserStates.set(metadata.id, browserState); } catch (error) { Logger_1.appLogger.warn(`Failed to resolve browser state for flow ${metadata.id}`, error); } } } let defaultGptConfig; try { const defaultGptConfigName = await this.agentsManager.get('flow-runner'); defaultGptConfig = defaultGptConfigName ? await this.gptConfigsManager.get(defaultGptConfigName) : undefined; } catch (error) { Logger_1.appLogger.warn('Error while resolving default GPT client configuration when generating project code', error); } return await (0, CodeGenerator_1.generateProject)(flowsWithToolCalls, resolvedBrowserStates, defaultGptConfig, options, this.toolRegistry); } /** * Resolves a GPT client using the provided GPT configuration name, falling back * to environment-provided configs (BASE64_GPT_CONFIG, DONOBU_API_KEY, * AWS_BEDROCK_MODEL_NAME, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, * OPENAI_API_KEY, OLLAMA_MODEL_NAME) and finally the config bound to the * 'flow-runner' agent. * Returns a null client if no configuration source is available. * * Public for testing only. **/ async createGptClient(gptConfigName) { if (gptConfigName) { try { const gptConfig = await this.gptConfigsManager.get(gptConfigName); const gptClient = await this.gptClientFactory.createClient(gptConfig); return { gptConfigName: gptConfigName, agentName: null, gptClient: gptClient, }; } catch (_error) { Logger_1.appLogger.warn(`Failed to find GPT configuration: ${gptConfigName}, will default to a GPT configuration set via environment variables, or the 'flow-runner' agent config (if it exists).`); } } const envModelDefaults = { anthropic: this.environ.data.ANTHROPIC_MODEL_NAME || 'claude-sonnet-4-5', googleGemini: this.environ.data.GOOGLE_GENERATIVE_AI_MODEL_NAME || 'gemini-2.5-flash', openAi: this.environ.data.OPENAI_API_MODEL_NAME || 'gpt-5.1', }; let gptConfigFromEnv = null; if (this.environ.data.BASE64_GPT_CONFIG) { let json; try { const base64decoded = Buffer.from(this.environ.data.BASE64_GPT_CONFIG, 'base64').toString('utf-8'); json = JSON.parse(base64decoded); } catch { throw new InvalidParamValueException_1.InvalidParamValueException('BASE64_GPT_CONFIG', '*** REDACTED ***', 'unable to interpret value as a base64-encoded JSON object.'); } gptConfigFromEnv = GptConfig_1.GptConfigInputSchema.parse(json); } else if (this.environ.data.DONOBU_API_KEY) { gptConfigFromEnv = { type: 'DONOBU', apiKey: this.environ.data.DONOBU_API_KEY, }; } else if (this.environ.data.AWS_BEDROCK_MODEL_NAME) { gptConfigFromEnv = { type: 'ANTHROPIC_AWS_BEDROCK', modelName: this.environ.data.AWS_BEDROCK_MODEL_NAME, accessKeyId: this.environ.data.AWS_ACCESS_KEY_ID, secretAccessKey: this.environ.data.AWS_SECRET_ACCESS_KEY, }; } else if (this.environ.data.ANTHROPIC_API_KEY) { gptConfigFromEnv = { type: 'ANTHROPIC', modelName: envModelDefaults.anthropic, apiKey: this.environ.data.ANTHROPIC_API_KEY, }; } else if (this.environ.data.GOOGLE_GENERATIVE_AI_API_KEY) { gptConfigFromEnv = { type: 'GOOGLE_GEMINI', modelName: envModelDefaults.googleGemini, apiKey: this.environ.data.GOOGLE_GENERATIVE_AI_API_KEY, }; } else if (this.environ.data.OPENAI_API_KEY) { gptConfigFromEnv = { type: 'OPENAI', modelName: envModelDefaults.openAi, apiKey: this.environ.data.OPENAI_API_KEY, }; } else if (this.environ.data.OLLAMA_MODEL_NAME) { gptConfigFromEnv = { type: 'OLLAMA', modelName: this.environ.data.OLLAMA_MODEL_NAME, ...(this.environ.data.OLLAMA_API_URL ? { apiUrl: this.environ.data.OLLAMA_API_URL } : {}), }; } if (gptConfigFromEnv) { const gptClient = await this.gptClientFactory.createClient(gptConfigFromEnv); return { gptConfigName: null, agentName: null, gptClient: gptClient, }; } const defaultGptConfigName = await this.agentsManager.get('flow-runner'); if (!defaultGptConfigName) { return { gptConfigName: null, agentName: null, gptClient: null, }; } const defaultGptConfig = await this.gptConfigsManager.get(defaultGptConfigName); const gptClient = await this.gptClientFactory.createClient(defaultGptConfig); return { gptConfigName: defaultGptConfigName, agentName: 'flow-runner', gptClient: gptClient, }; } /** * Loads the browser state associated with the given flow. Throws * {@link BrowserStateNotFoundException} if it is not found. */ async getBrowserStorageState(browserStateRef) { let flowMetadata; switch (browserStateRef.type) { case 'id': flowMetadata = await this.getFlowById(browserStateRef.value); break; case 'name': flowMetadata = await this.getFlowByName(browserStateRef.value); break; case 'testId': { // Resolve the test ID to its most recent successful flow. const { items } = await this.getFlows({ testId: browserStateRef.value, state: 'SUCCESS', sortBy: 'created_at', sortOrder: 'desc', limit: 1, }); if (items.length === 0) { throw new BrowserStateNotFoundException_1.BrowserStateNotFoundException(`test:${browserStateRef.value}`); } flowMetadata = items[0]; break; } case 'json': return browserStateRef.value; default: throw new InvalidParamValueException_1.InvalidParamValueException('type', browserStateRef.type); } const browserState = await this.flowCatalog.getBrowserState(flowMetadata.id); if (browserState) { return browserState; } throw new BrowserStateNotFoundException_1.BrowserStateNotFoundException(flowMetadata.id); } /** * Picks the flows persistence layer to use when creating a new flow. * * - If `testId` is null/undefined: use the primary flows layer. * - If `testId` is set: look up the test's layer key and use the matching * flows layer. If no flows layer matches the test's key (rare — * asymmetric registry config), fall back to the primary layer; the FK * won't hold but the flow is at least persisted. * - If `testId` is set but no test exists with that ID anywhere: fall * back to the primary layer (the SQLite FK will reject if applicable; * non-DB layers will accept the dangling reference). */ /** * Best-effort check that no flow with the given ID exists in any persistence * layer. Throws {@link FlowIdCollisionException} on hit. Note: this is RACY * — concurrent callers with the same ID can both pass the check. */ async assertFlowIdAvailable(flowId) { for (const persistence of await this.flowsPersistenceRegistry.getAll()) { try { await persistence.getFlowMetadataById(flowId); throw FlowIdCollisionException_1.FlowIdCollisionException.forId(flowId); } catch (error) { if (error instanceof FlowIdCollisionException_1.FlowIdCollisionException) { throw error; } if (!(error instanceof FlowNotFoundException_1.FlowNotFoundException)) { throw error; } } } } async resolveLayerForFlowCreate(testId) { if (!testId) { return this.flowsPersistenceRegistry.get(); } let testLayerKey; try { testLayerKey = await this.findTestLayerKey(testId); } catch (error) { if (!(error instanceof TestNotFoundException_1.TestNotFoundException)) { throw error; } return this.flowsPersistenceRegistry.get(); } const matched = await this.flowsPersistenceRegistry.getByKey(testLayerKey); return matched ?? (await this.flowsPersistenceRegistry.get()); } async findTestLayerKey(testId) { for (const { key, persistence, } of await this.testsPersistenceRegistry.getEntries()) { try { await persistence.getTestById(testId); return key; } catch (error) { if (!(error instanceof TestNotFoundException_1.TestNotFoundException)) { throw error; } } } throw TestNotFoundException_1.TestNotFoundException.forId(testId); } isLocallyRunning() { return this.deploymentEnvironment === 'LOCAL'; } } exports.DonobuFlowsManager = DonobuFlowsManager; DonobuFlowsManager.DEFAULT_MESSAGE_DURATION = 2247; // If you change this value, also consider changing the frontend default // value in createFlowDefaults in flow-types.ts. DonobuFlowsManager.DEFAULT_MAX_TOOL_CALLS = 50; DonobuFlowsManager.DEFAULT_BROWSER_STATE_FILENAME = 'browserstate.json'; DonobuFlowsManager.FLOW_NAME_MAX_LENGTH = 255; /** * Extracts environment variable names from the given objective and combines * it with the given explicitly allowed variables. * * This function performs two operations: * 1. Extracts environment variable references (in the form `$.env.VARIABLE_NAME`) from the overall objective. * 2. Combines these with any explicitly allowed environment variable names. * * The resulting array contains unique environment variable names without duplicates. * * @param overallObjective - The objective text that may contain environment variable references. * @param explicitlyAllowedEnvVariableNames - Additional environment variable names explicitly allowed. * @returns An array of unique environment variable names that are allowed to be accessed. * * @example * // Returns ["API_KEY", "USER_NAME", "DEBUG_MODE"] * distillAllowedEnvVariableNames( * "Use {{$.env.API_KEY}} to authenticate and greet {{$.env.USER_NAME}}", * ["API_KEY", "DEBUG_MODE"] * ); */ function distillAllowedEnvVariableNames(overallObjective, explicitlyAllowedEnvVariableNames) { let allowedEnvVarsByName = overallObjective ? (0, TemplateInterpolator_1.extractInterpolationExpressions)(overallObjective) .filter((exp) => { return exp.startsWith('$.env.'); }) .map((exp) => { return exp.substring('$.env.'.length); }) : []; // Concatonate that with the explicitly requested environment variables. allowedEnvVarsByName = Array.from(new Set(allowedEnvVarsByName.concat(explicitlyAllowedEnvVariableNames ?? []))); return allowedEnvVarsByName; } /** * Parses the given argument as a URL, returning null on failure. */ function parseUrl(url) { if (!url) { return null; } try { return new URL(url); } catch (_) { return null; } } function validateFlowName(flowName) { if (flowName && flowName.length > DonobuFlowsManager.FLOW_NAME_MAX_LENGTH) { throw new InvalidParamValueException_1.InvalidParamValueException('name', flowName, `the value cannot be longer than ${DonobuFlowsManager.FLOW_NAME_MAX_LENGTH} characters`); } } async function validateFlowParams(flowParams, gptClient, initialRunMode, toolRegistry) { // Target-specific validation (e.g. targetWebsite URL format) is handled // by the TargetRuntimePlugin.validate() call in resolveTargetRuntime(). // This function only validates target-agnostic parameters. const validCallbackUrlProtocols = ['https:', 'http:']; const parsedCallbackUrl = parseUrl(flowParams.callbackUrl); if (parsedCallbackUrl && !validCallbackUrlProtocols.some((protocol) => protocol === parsedCallbackUrl.protocol)) { throw new InvalidParamValueException_1.InvalidParamValueException('callbackUrl', flowParams.callbackUrl, 'the URL must start with a supported protocol (example: "https://")'); } else if (flowParams.callbackUrl && !parsedCallbackUrl) { throw new InvalidParamValueException_1.InvalidParamValueException('callbackUrl', flowParams.callbackUrl, 'the URL is malformed'); } validateFlowName(flowParams.name); switch (initialRunMode) { case 'AUTONOMOUS': if ((flowParams.overallObjective?.trim().length ?? 0) === 0) { throw new InvalidParamValueException_1.InvalidParamValueException('overallObjective', flowParams.overallObjective, `'initialRunMode' has a value of '${initialRunMode}'`); } if (!gptClient) { throw new InvalidParamValueException_1.InvalidParamValueException('initialRunMode', initialRunMode, `no GPT client is available`); } break; case 'INSTRUCT': break; case 'DETERMINISTIC': break; default: throw new InvalidParamValueException_1.InvalidParamValueException('initialRunMode', initialRunMode); } if (!gptClient) { await throwIfAnyToolsRequireGpt(flowParams.allowedTools ?? null, flowParams.toolCallsOnStart ?? null, toolRegistry); } } /** * Resolves the final set of tools a flow is permitted to use. * * The resolution follows this priority: * 1. If the caller explicitly listed tools, use those (filtered to the target). * 2. Otherwise, fall back to target-appropriate defaults (curated subset for * web, all target-compatible tools for plugin-provided targets). * 3. Tools required by `toolCallsOnStart` are always included. * 4. Custom (user-defined) tools are prepended. * 5. GPT-requiring tools are stripped when no GPT client is available. * 6. Minimal tools (objective bookkeeping) are guaranteed present. * 7. The result is deduplicated and sorted by name. */ /** @internal Exported for testing. */ async function setupAllowedTools(flowParams, hasGptClient, targetType, toolRegistry) { const customTools = flowParams.customTools?.map((tool) => new CustomToolRunnerTool_1.CustomToolRunnerTool(tool)) ?? []; const toolsNeededOnStart = flowParams.toolCallsOnStart?.map((t) => t.name) ?? []; const targetCompatibleTools = toolRegistry.toolsForTarget(targetType); let prepackagedTools; if (flowParams.allowedTools?.length) { const allowedTools = [...toolsNeededOnStart, ...flowParams.allowedTools]; // The user has specified a list of tools to use. prepackagedTools = targetCompatibleTools.filter((tool) => allowedTools.includes(tool.name)); } else { // The user has not specified a list of tools to use, so use the // curated defaults. Target-specific tools (e.g. mobile plugin tools) // still reach the final set because they are in `defaultTools()` via // `pluginTools` and survive the `targetCompatibleTools` filter below. // Critically, this keeps tools like `triggerDonobuFlow` — whose GPT // schema is not representable as JSON Schema — out of the default // tool set across all targets. const defaultTools = toolRegistry.defaultTools(); const allowedTools = [ ...toolsNeededOnStart, ...defaultTools.map((t) => t.name), ]; prepackagedTools = targetCompatibleTools.filter((tool) => allowedTools.includes(tool.name)); } // If there is no GPT client, only include tools that do not require it. const prepackagedToolsWithGptFiltered = prepackagedTools.filter((tool) => { return hasGptClient || !tool.requiresGpt; }); const minimalTools = toolRegistry.minimalTools(); const existingToolNames = new Set([...customTools, ...prepackagedToolsWithGptFiltered].map((tool) => tool.name)); const missingMinimalTools = minimalTools.filter((tool) => !existingToolNames.has(tool.name)); const combinedTools = [ ...customTools, ...prepackagedToolsWithGptFiltered, ...missingMinimalTools, ]; const dedupedTools = Array.from(combinedTools .reduce((uniqueTools, tool) => { if (!uniqueTools.has(tool.name)) { uniqueTools.set(tool.name, tool); } return uniqueTools; }, new Map()) .values()); return dedupedTools.sort((a, b) => a.name.localeCompare(b.name)); } async function removeTempDirectoryForFlow(flowId) { try { const tempDir = path.join((0, os_1.tmpdir)(), flowId); await fs.rm(tempDir, { recursive: true, force: true }); } catch (error) { Logger_1.appLogger.error('Failed to remove temporary directory:', error); } } /** * Searches the given directory for the largest video file and sets it as the * flow's video. This should be called after the flow has completed. */ async function setFlowVideo(flowId, flowTempDir, flowsPersistence) { const files = (await fs.readdir(flowTempDir)) .filter((file) => file.endsWith('.webm')) .map((file) => path.join(flowTempDir, file)); if (!files.length) { return; } const filesWithSizes = await Promise.all(files.map(async (filePath) => ({ filePath, size: (await fs.stat(filePath)).size, }))); const largestFile = filesWithSizes.reduce((largest, current) => current.size > largest.size ? current : largest); const videoPath = largestFile?.filePath ?? null; if (videoPath) { const videoBytes = await fs.readFile(videoPath); await flowsPersistence.setVideo(flowId, videoBytes); } } async function throwIfAnyToolsRequireGpt(requestedTools, toolCallsOnStart, toolRegistry) { const toolMap = new Map(toolRegistry.allTools().map((tool) => [tool.name, tool])); const requestedToolsRequiringGpt = [...(requestedTools ?? [])].filter((name) => toolMap.get(name)?.requiresGpt); if (requestedToolsRequiringGpt.length) { throw new ToolRequiresGptException_1.ToolRequiresGptException(requestedToolsRequiringGpt[0]); } const toolCallsRequiringGpt = toolCallsOnStart?.filter((call) => toolMap.get(call.name)?.requiresGpt); if (toolCallsRequiringGpt?.length) { throw new ToolRequiresGptException_1.ToolRequiresGptException(toolCallsRequiringGpt[0].name); } } /** * Convert an *executed* list of {@link ToolCall}s into a list of * {@link ProposedToolCall}s that can be replayed in a fresh session. * * For each call, we look up the {@link Tool} in the given registry and * delegate to its `prepareForRerun` override. Tools that need selector * remapping (e.g. {@link ClickTool}, {@link InputTextTool}, the mobile * interaction tools) each own their own logic; tools with no replay- * specific needs inherit the passthrough default from {@link Tool}. * * When a tool is not registered (stale flow, removed tool) or * `prepareForRerun` throws (e.g. missing selector metadata), the call * is passed through or skipped with an `appLogger.warn` — "best-effort * replay" behavior is preserved. * * @returns A list of {@link ProposedToolCall}s ready for replay. */ async function prepareToolCallsForRerun(toolCalls, options, toolRegistry) { const toolsByName = new Map(toolRegistry.allTools().map((tool) => [tool.name, tool])); const proposedToolCalls = []; for (const toolCall of toolCalls) { const tool = toolsByName.get(toolCall.toolName); if (!tool) { // Tool no longer registered (e.g. stale flow referencing a removed // tool). Passthrough so we don't silently drop the call. proposedToolCalls.push({ name: toolCall.toolName, parameters: toolCall.parameters, }); continue; } try { proposedToolCalls.push(tool.prepareForRerun(toolCall, options)); } catch (e) { Logger_1.appLogger.warn(`Failed to prepare tool call for rerun: ${JSON.stringify(toolCall)}`, e); } } return proposedToolCalls; } //# sourceMappingURL=DonobuFlowsManager.js.map