@debugg-ai/debugg-ai-mcp
Version:
Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.
155 lines (154 loc) • 7.86 kB
JavaScript
/**
* Workflows Service
* 4-step integration: find template → execute → poll → result
*/
import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
// Exponential backoff polling: short executions (10-15s crawls) detect terminal
// status quickly via the early polls; long executions (60-150s browser runs)
// avoid hammering the backend with 20+ roundtrips. Cap at 5s so we never wait
// more than 5s past terminal-state achievement.
const POLL_INTERVAL_INITIAL_MS = 1000;
const POLL_INTERVAL_MAX_MS = 5000;
const POLL_BACKOFF_MULTIPLIER = 1.5;
const EXECUTION_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
export const createWorkflowsService = (tx) => {
const service = {
async findTemplateByName(keyword) {
// Narrow server-side with `search` AND walk every page. The backend caps
// the page size (it ignores page_size), so reading only page 1 silently
// hides templates that sort later — that bug made check_app_in_browser
// fail in prod because "App Evaluation Workflow Template" sat on page 2.
// `search` collapses the candidate set to one page on backends that
// support it; `page` paging is the fallback for those that ignore it.
const needle = keyword.toLowerCase();
const seenNames = [];
const MAX_PAGES = 50; // safety valve against a backend that always returns `next`
for (let page = 1; page <= MAX_PAGES; page++) {
const response = await tx.get('api/v1/workflows/', { isTemplate: true, search: keyword, page });
const templates = response?.results ?? [];
for (const t of templates) {
seenNames.push(t.name);
if (t.name.toLowerCase().includes(needle))
return t;
}
if (!response?.next)
break;
}
if (seenNames.length === 0)
return null;
throw new Error(`No workflow template matching "${keyword}" found. ` +
`Available templates: ${seenNames.map(n => `"${n}"`).join(', ')}. ` +
`Ensure the backend has a template with "${keyword}" in its name.`);
},
async findEvaluationTemplate() {
// 'app evaluation workflow' is specific enough to skip 'App Evaluation Brain'
// (subworkflow, no browser lifecycle) which also contains 'app evaluation'.
const keyword = process.env.DEBUGGAI_EVAL_TEMPLATE || 'app evaluation workflow';
return service.findTemplateByName(keyword);
},
async executeWorkflow(workflowUuid, contextData, env) {
const body = { contextData };
// Send projectId at top level too — backend may read it from either location
if (contextData.projectId) {
body.projectId = contextData.projectId;
}
if (env && Object.keys(env).length > 0) {
body.env = env;
}
const response = await tx.post(`api/v1/workflows/${workflowUuid}/execute/`, body);
if (!response?.resourceUuid) {
throw new Error('Workflow execution failed: no execution UUID returned');
}
return {
executionUuid: response.resourceUuid,
resolvedEnvironmentId: response.resolvedEnvironmentId ?? null,
resolvedCredentialId: response.resolvedCredentialId ?? null,
};
},
async getExecution(executionUuid) {
const response = await tx.get(`api/v1/workflows/executions/${executionUuid}/`);
if (!response) {
throw new Error(`Execution not found: ${executionUuid}`);
}
return response;
},
async listExecutions(filters) {
const { makePageInfo } = await import('../utils/pagination.js');
const params = { page: filters.page, pageSize: filters.pageSize };
if (filters.status)
params.status = filters.status;
if (filters.projectId)
params.projectId = filters.projectId;
const response = await tx.get('api/v1/workflows/executions/', params);
return {
pageInfo: makePageInfo(filters.page, filters.pageSize, response?.count ?? 0, response?.next),
executions: (response?.results ?? []).map((e) => ({
uuid: e.uuid,
workflow: e.workflow,
status: e.status,
mode: e.mode,
source: e.source,
outcome: e.outcome ?? null,
startedAt: e.startedAt,
completedAt: e.completedAt,
durationMs: e.durationMs,
timestamp: e.timestamp,
})),
};
},
async cancelExecution(executionUuid) {
await tx.post(`api/v1/workflows/executions/${executionUuid}/cancel/`, {});
},
async pollExecution(executionUuid, onUpdate, signal) {
const deadline = Date.now() + EXECUTION_TIMEOUT_MS;
const pollStart = Date.now();
let pollCount = 0;
let intervalMs = POLL_INTERVAL_INITIAL_MS;
while (Date.now() < deadline) {
if (signal?.aborted) {
throw new Error(`Polling cancelled for execution ${executionUuid}`);
}
const execution = await service.getExecution(executionUuid);
pollCount++;
if (onUpdate) {
await onUpdate(execution).catch(() => { });
}
if (TERMINAL_STATUSES.has(execution.status)) {
Telemetry.capture(TelemetryEvents.WORKFLOW_EXECUTED, {
status: execution.status,
success: execution.state?.success ?? false,
outcome: execution.state?.outcome ?? null,
stepsTaken: execution.state?.stepsTaken ?? 0,
durationMs: Date.now() - pollStart,
pollCount,
finalIntervalMs: intervalMs,
});
return execution;
}
// Check abort before sleeping to avoid missing a signal fired between polls
if (signal?.aborted) {
throw new Error(`Polling cancelled for execution ${executionUuid}`);
}
const sleepMs = intervalMs;
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, sleepMs);
if (signal) {
const onAbort = () => { clearTimeout(timer); reject(new Error(`Polling cancelled for execution ${executionUuid}`)); };
if (signal.aborted) {
clearTimeout(timer);
reject(new Error(`Polling cancelled for execution ${executionUuid}`));
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
});
// Backoff for next iteration — capped at MAX so we don't wait too long
// past terminal-state achievement on the longest runs.
intervalMs = Math.min(Math.round(intervalMs * POLL_BACKOFF_MULTIPLIER), POLL_INTERVAL_MAX_MS);
}
throw new Error(`Execution ${executionUuid} timed out after ${EXECUTION_TIMEOUT_MS / 1000}s`);
}
};
return service;
};