UNPKG

@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
/** * 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; };