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