mirror of
https://github.com/getmaxun/maxun.git
synced 2026-10-20 06:07:33 +08:00
701 lines
33 KiB
TypeScript
701 lines
33 KiB
TypeScript
import { run, Runner, TaskList } from 'graphile-worker';
|
|
import { Pool } from 'pg';
|
|
import logger from './logger';
|
|
import {
|
|
initializeRemoteBrowserForRecording,
|
|
destroyRemoteBrowser,
|
|
interpretWholeWorkflow,
|
|
stopRunningInterpretation,
|
|
} from './browser-management/controller';
|
|
import { WorkflowFile } from 'maxun-core';
|
|
import Run from './models/Run';
|
|
import Robot from './models/Robot';
|
|
import { browserPool } from './server';
|
|
import { Page } from 'playwright-core';
|
|
import { capture } from './utils/analytics';
|
|
import { addGoogleSheetUpdateTask, processGoogleSheetUpdates } from './workflow-management/integrations/gsheet';
|
|
import { addAirtableUpdateTask, processAirtableUpdates } from './workflow-management/integrations/airtable';
|
|
import { io as serverIo } from './server';
|
|
import { sendWebhook } from './routes/webhook';
|
|
import { BinaryOutputService } from './storage/mino';
|
|
import { convertPageToMarkdown, convertPageToHTML, convertPageToLinks, convertPageToScreenshot, convertPageToText } from './markdownify/scrape';
|
|
import { executeBrowserAgent } from './sdk/browserAgent';
|
|
import { processRobotOutputFormats } from './utils/output-post-processor';
|
|
import { getInterpretationFailureReason, hasExpectedRobotOutput } from './utils/output-validation';
|
|
import { handleRunRecording } from './workflow-management/scheduler';
|
|
import dotenv from 'dotenv';
|
|
|
|
dotenv.config();
|
|
|
|
if (!process.env.DB_USER || !process.env.DB_PASSWORD || !process.env.DB_HOST || !process.env.DB_PORT || !process.env.DB_NAME) {
|
|
throw new Error('Failed to start task runner: one or more required environment variables are missing.');
|
|
}
|
|
|
|
const connectionString = `postgresql://${process.env.DB_USER}:${encodeURIComponent(process.env.DB_PASSWORD!)}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`;
|
|
const useSSL = process.env.DB_SSL === 'true';
|
|
|
|
export const QUEUE_NAMES = {
|
|
INITIALIZE_BROWSER_RECORDING: 'initialize-browser-recording',
|
|
DESTROY_BROWSER: 'destroy-browser',
|
|
INTERPRET_WORKFLOW: 'interpret-workflow',
|
|
STOP_INTERPRETATION: 'stop-interpretation',
|
|
EXECUTE_RUN: 'execute-run',
|
|
ABORT_RUN: 'abort-run',
|
|
SCHEDULED_WORKFLOW: 'scheduled-workflow',
|
|
} as const;
|
|
|
|
interface InitializeBrowserData {
|
|
userId: string;
|
|
}
|
|
|
|
interface InterpretWorkflow {
|
|
userId: string;
|
|
}
|
|
|
|
interface StopInterpretWorkflow {
|
|
userId: string;
|
|
}
|
|
|
|
interface DestroyBrowserData {
|
|
browserId: string;
|
|
userId: string;
|
|
}
|
|
|
|
interface ExecuteRunData {
|
|
userId: string;
|
|
runId: string;
|
|
browserId: string;
|
|
}
|
|
|
|
interface AbortRunData {
|
|
userId: string;
|
|
runId: string;
|
|
}
|
|
|
|
interface ScheduledWorkflowData {
|
|
robotMetaId: string;
|
|
userId: string;
|
|
}
|
|
|
|
const TOTAL_CONCURRENCY = Math.max(1, parseInt(process.env.WORKER_CONCURRENCY || '10', 10));
|
|
|
|
function AddGeneratedFlags(workflow: WorkflowFile) {
|
|
const copy = JSON.parse(JSON.stringify(workflow));
|
|
for (let i = 0; i < workflow.workflow.length; i++) {
|
|
copy.workflow[i].what.unshift({
|
|
action: 'flag',
|
|
args: ['generated'],
|
|
});
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, operation: string): Promise<T> {
|
|
return Promise.race([
|
|
promise,
|
|
new Promise<T>((_, reject) =>
|
|
setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
)
|
|
]);
|
|
}
|
|
|
|
async function triggerIntegrationUpdates(runId: string, robotMetaId: string): Promise<void> {
|
|
try {
|
|
addGoogleSheetUpdateTask(runId, { robotId: robotMetaId, runId, status: 'pending', retries: 5 });
|
|
addAirtableUpdateTask(runId, { robotId: robotMetaId, runId, status: 'pending', retries: 5 });
|
|
|
|
withTimeout(processAirtableUpdates(), 65000, 'Airtable update')
|
|
.catch(err => logger.log('error', `Airtable update error: ${err.message}`));
|
|
withTimeout(processGoogleSheetUpdates(), 65000, 'Google Sheets update')
|
|
.catch(err => logger.log('error', `Google Sheets update error: ${err.message}`));
|
|
} catch (err: any) {
|
|
logger.log('error', `Failed to update integrations for run: ${runId}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
const getRobotTargetUrl = (recording: any): string => {
|
|
const metaUrl = recording?.recording_meta?.url?.trim();
|
|
if (metaUrl) return metaUrl;
|
|
|
|
const workflow = recording?.recording?.workflow || [];
|
|
const entryPair = [...workflow].reverse().find((pair: any) =>
|
|
pair?.what?.some((action: any) => action.action === 'goto' && typeof action.args?.[0] === 'string' && action.args[0] !== 'about:blank'),
|
|
);
|
|
const gotoUrl = entryPair?.what?.find((action: any) => action.action === 'goto' && typeof action.args?.[0] === 'string')?.args?.[0]?.trim();
|
|
if (gotoUrl) return gotoUrl;
|
|
|
|
return workflow.find((pair: any) => typeof pair?.where?.url === 'string' && pair.where.url !== 'about:blank')?.where?.url?.trim() || '';
|
|
};
|
|
|
|
async function processRunExecution(data: ExecuteRunData): Promise<void> {
|
|
const BROWSER_INIT_TIMEOUT = 60000;
|
|
const BROWSER_PAGE_TIMEOUT = 15000;
|
|
|
|
logger.log('info', `Processing run execution job for runId: ${data.runId}, browserId: ${data.browserId}`);
|
|
|
|
try {
|
|
const run = await Run.findOne({ where: { runId: data.runId } });
|
|
if (!run) {
|
|
logger.log('error', `Run ${data.runId} not found in database`);
|
|
return;
|
|
}
|
|
|
|
if (run.status === 'aborted' || run.status === 'aborting') {
|
|
logger.log('info', `Run ${data.runId} has status ${run.status}, skipping execution`);
|
|
return;
|
|
}
|
|
|
|
if (run.status === 'queued') {
|
|
logger.log('info', `Run ${data.runId} has status 'queued', skipping stale execution job`);
|
|
return;
|
|
}
|
|
|
|
const plainRun = run.toJSON();
|
|
|
|
if ((plainRun.interpreterSettings as any)?.robotType === 'doc-extract') {
|
|
logger.log('info', `Run ${data.runId} is a document robot — skipping browser, running document extraction`);
|
|
const recording = await Robot.findOne({ where: { 'recording_meta.id': plainRun.robotMetaId }, raw: true });
|
|
if (!recording) {
|
|
logger.log('error', `Robot not found for document run ${data.runId}`);
|
|
await run.update({ status: 'failed', finishedAt: new Date().toLocaleString(), log: 'Robot not found' });
|
|
return;
|
|
}
|
|
const { executeDocumentRun } = await import('./utils/document/executeDocumentRun');
|
|
await executeDocumentRun(recording, run, data.userId, serverIo);
|
|
return;
|
|
}
|
|
|
|
if ((plainRun.interpreterSettings as any)?.robotType === 'doc-parse') {
|
|
logger.log('info', `Run ${data.runId} is a document-parse robot — skipping browser, running document parsing`);
|
|
const recording = await Robot.findOne({ where: { 'recording_meta.id': plainRun.robotMetaId }, raw: true });
|
|
if (!recording) {
|
|
logger.log('error', `Robot not found for document-parse run ${data.runId}`);
|
|
await run.update({ status: 'failed', finishedAt: new Date().toLocaleString(), log: 'Robot not found' });
|
|
return;
|
|
}
|
|
const { executeDocumentParseRun } = await import('./utils/document/executeDocumentParseRun');
|
|
await executeDocumentParseRun(recording, run, data.userId, serverIo);
|
|
return;
|
|
}
|
|
|
|
const browserId = data.browserId || plainRun.browserId;
|
|
|
|
if (!browserId) throw new Error(`No browser ID available for run ${data.runId}`);
|
|
|
|
logger.log('info', `Looking for browser ${browserId} for run ${data.runId}`);
|
|
|
|
let browser = browserPool.getRemoteBrowser(browserId);
|
|
const browserWaitStart = Date.now();
|
|
let lastLogTime = 0;
|
|
let pollAttempts = 0;
|
|
const MAX_POLL_ATTEMPTS = Math.ceil(BROWSER_INIT_TIMEOUT / 2000);
|
|
|
|
while (!browser && (Date.now() - browserWaitStart) < BROWSER_INIT_TIMEOUT && pollAttempts < MAX_POLL_ATTEMPTS) {
|
|
const currentTime = Date.now();
|
|
pollAttempts++;
|
|
|
|
const browserStatus = browserPool.getBrowserStatus(browserId);
|
|
if (browserStatus === null) throw new Error(`Browser slot ${browserId} does not exist in pool`);
|
|
if (browserStatus === 'failed') throw new Error(`Browser ${browserId} initialization failed`);
|
|
|
|
if (currentTime - lastLogTime > 10000) {
|
|
logger.log('info', `Browser ${browserId} not ready yet (status: ${browserStatus}), waiting... (${Math.round((currentTime - browserWaitStart) / 1000)}s elapsed)`);
|
|
lastLogTime = currentTime;
|
|
}
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
browser = browserPool.getRemoteBrowser(browserId);
|
|
}
|
|
|
|
if (!browser) {
|
|
const finalStatus = browserPool.getBrowserStatus(browserId);
|
|
throw new Error(`Browser ${browserId} not found in pool after ${BROWSER_INIT_TIMEOUT / 1000}s timeout (final status: ${finalStatus})`);
|
|
}
|
|
|
|
logger.log('info', `Browser ${browserId} found and ready for execution`);
|
|
|
|
try {
|
|
const recording = await Robot.findOne({ where: { 'recording_meta.id': plainRun.robotMetaId }, raw: true });
|
|
if (!recording) throw new Error(`Recording for run ${data.runId} not found`);
|
|
|
|
let currentPage = browser.getCurrentPage();
|
|
const pageWaitStart = Date.now();
|
|
let lastPageLogTime = 0;
|
|
let pageAttempts = 0;
|
|
|
|
while (!currentPage && (Date.now() - pageWaitStart) < BROWSER_PAGE_TIMEOUT && pageAttempts < 15) {
|
|
const currentTime = Date.now();
|
|
pageAttempts++;
|
|
if (currentTime - lastPageLogTime > 5000) {
|
|
logger.log('info', `Page not ready for browser ${browserId}, waiting...`);
|
|
lastPageLogTime = currentTime;
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
currentPage = browser.getCurrentPage();
|
|
}
|
|
|
|
if (!currentPage) throw new Error(`No current page available for browser ${browserId} after ${BROWSER_PAGE_TIMEOUT / 1000}s timeout`);
|
|
|
|
if (recording.recording_meta.type === 'scrape') {
|
|
logger.log('info', `Executing scrape robot for run ${data.runId}`);
|
|
|
|
const rawFormats = run.interpreterSettings?.formats || recording.recording_meta.formats;
|
|
const formats = rawFormats && rawFormats.length > 0 ? rawFormats : ['markdown'];
|
|
|
|
await run.update({ status: 'running', log: `Converting page to ${formats.join(', ')}` });
|
|
|
|
try {
|
|
const url = getRobotTargetUrl(recording);
|
|
if (!url) throw new Error('No URL specified for scrape robot');
|
|
|
|
const serializableOutput: any = {};
|
|
const binaryOutput: any = {};
|
|
const SCRAPE_TIMEOUT = 120000;
|
|
|
|
if (formats.includes('screenshot-visible')) {
|
|
const buf = await Promise.race([convertPageToScreenshot(url, currentPage, false), new Promise<never>((_, r) => setTimeout(() => r(new Error('timeout')), SCRAPE_TIMEOUT))]);
|
|
if (!binaryOutput['screenshot-visible']) binaryOutput['screenshot-visible'] = { data: buf.toString('base64'), mimeType: 'image/png' };
|
|
}
|
|
|
|
if (formats.includes('screenshot-fullpage')) {
|
|
const buf = await Promise.race([convertPageToScreenshot(url, currentPage, true), new Promise<never>((_, r) => setTimeout(() => r(new Error('timeout')), SCRAPE_TIMEOUT))]);
|
|
if (!binaryOutput['screenshot-fullpage']) binaryOutput['screenshot-fullpage'] = { data: buf.toString('base64'), mimeType: 'image/png' };
|
|
}
|
|
|
|
if (formats.includes('text')) {
|
|
const text = await Promise.race([convertPageToText(url, currentPage), new Promise<never>((_, r) => setTimeout(() => r(new Error('timeout')), SCRAPE_TIMEOUT))]);
|
|
if (text) serializableOutput.text = [{ content: text }];
|
|
}
|
|
|
|
if (formats.includes('markdown')) {
|
|
const markdown = await Promise.race([convertPageToMarkdown(url, currentPage), new Promise<never>((_, r) => setTimeout(() => r(new Error('timeout')), SCRAPE_TIMEOUT))]);
|
|
serializableOutput.markdown = [{ content: markdown }];
|
|
}
|
|
|
|
if (formats.includes('html')) {
|
|
const html = await Promise.race([convertPageToHTML(url, currentPage), new Promise<never>((_, r) => setTimeout(() => r(new Error('timeout')), SCRAPE_TIMEOUT))]);
|
|
serializableOutput.html = [{ content: html }];
|
|
}
|
|
|
|
if (formats.includes('links')) {
|
|
try {
|
|
const links = await Promise.race([convertPageToLinks(url, currentPage), new Promise<never>((_, r) => setTimeout(() => r(new Error('timeout')), SCRAPE_TIMEOUT))]);
|
|
if (links && links.length > 0) serializableOutput.links = links.map((link: string) => ({ url: link }));
|
|
} catch (error: any) {
|
|
logger.log('warn', `Links extraction failed for run ${data.runId}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
const promptInstructions = run.interpreterSettings?.promptInstructions || (recording.recording_meta as any).promptInstructions as string | undefined;
|
|
if (promptInstructions) {
|
|
try {
|
|
const llmConfig = {
|
|
provider: ((recording.recording_meta as any).promptLlmProvider || 'ollama') as 'anthropic' | 'openai' | 'ollama',
|
|
model: (recording.recording_meta as any).promptLlmModel as string | undefined,
|
|
apiKey: (recording.recording_meta as any).promptLlmApiKey as string | undefined,
|
|
baseUrl: (recording.recording_meta as any).promptLlmBaseUrl as string | undefined,
|
|
};
|
|
await run.update({ log: 'Running smart query...' });
|
|
const agentResult = await executeBrowserAgent(currentPage, promptInstructions, llmConfig);
|
|
serializableOutput.promptResult = [{ content: agentResult.result, steps: agentResult.steps }];
|
|
} catch (agentError: any) {
|
|
logger.log('warn', `Smart query failed for run ${data.runId}: ${agentError.message}`);
|
|
serializableOutput.promptResult = [{ content: `Smart query failed: ${agentError.message}`, steps: [] }];
|
|
}
|
|
}
|
|
|
|
await run.update({ status: 'success', finishedAt: new Date().toLocaleString(), log: `${formats.join(', ').toUpperCase()} conversion completed successfully`, serializableOutput, binaryOutput });
|
|
|
|
let uploadedBinaryOutput: Record<string, string> = {};
|
|
if (Object.keys(binaryOutput).length > 0) {
|
|
const svc = new BinaryOutputService('maxun-run-screenshots');
|
|
uploadedBinaryOutput = await svc.uploadAndStoreBinaryOutput(run, binaryOutput);
|
|
await run.update({ binaryOutput: uploadedBinaryOutput });
|
|
}
|
|
|
|
try {
|
|
const completionData = { runId: data.runId, robotMetaId: plainRun.robotMetaId, robotName: recording.recording_meta.name, status: 'success', finishedAt: new Date().toLocaleString() };
|
|
serverIo.of(browserId).emit('run-completed', completionData);
|
|
serverIo.of('/queued-run').to(`user-${data.userId}`).emit('run-completed', completionData);
|
|
} catch (socketError: any) {
|
|
logger.log('warn', `Failed to send run-completed notification for run ${data.runId}: ${socketError.message}`);
|
|
}
|
|
|
|
try {
|
|
await sendWebhook(plainRun.robotMetaId, 'run_completed', { runId: data.runId, robotId: plainRun.robotMetaId, robotName: recording.recording_meta.name, status: 'success', finishedAt: new Date().toLocaleString() });
|
|
} catch (webhookError: any) {
|
|
logger.log('warn', `Failed to send webhooks for run ${data.runId}: ${webhookError.message}`);
|
|
}
|
|
|
|
capture('maxun-oss-run-created', { runId: data.runId, user_id: data.userId, status: 'success', robot_type: 'scrape', formats, source: 'manual' });
|
|
await destroyRemoteBrowser(browserId, data.userId);
|
|
return;
|
|
|
|
} catch (error: any) {
|
|
logger.log('error', `Scrape conversion failed for run ${data.runId}: ${error.message}`);
|
|
await run.update({ status: 'failed', finishedAt: new Date().toLocaleString(), log: `Conversion failed: ${error.message}` });
|
|
try {
|
|
const failureData = { runId: data.runId, robotMetaId: plainRun.robotMetaId, robotName: recording.recording_meta.name, status: 'failed', finishedAt: new Date().toLocaleString() };
|
|
serverIo.of(browserId).emit('run-completed', failureData);
|
|
serverIo.of('/queued-run').to(`user-${data.userId}`).emit('run-completed', failureData);
|
|
} catch (_) {}
|
|
capture('maxun-oss-run-created', { runId: data.runId, user_id: data.userId, status: 'failed', robot_type: 'scrape', source: 'manual' });
|
|
await destroyRemoteBrowser(browserId, data.userId);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const isRunAborted = async (): Promise<boolean> => {
|
|
try {
|
|
const currentRun = await Run.findOne({ where: { runId: data.runId } });
|
|
return currentRun ? (currentRun.status === 'aborted' || currentRun.status === 'aborting') : false;
|
|
} catch { return false; }
|
|
};
|
|
|
|
logger.log('info', `Starting workflow execution for run ${data.runId}`);
|
|
await run.update({ status: 'running', log: 'Workflow execution started' });
|
|
|
|
try {
|
|
const startedData = { runId: data.runId, robotMetaId: plainRun.robotMetaId, robotName: recording.recording_meta.name, status: 'running', startedAt: new Date().toLocaleString() };
|
|
serverIo.of(browserId).emit('run-started', startedData);
|
|
serverIo.of('/queued-run').to(`user-${data.userId}`).emit('run-started', startedData);
|
|
} catch (socketError: any) {
|
|
logger.log('warn', `Failed to send run-started notification for run ${data.runId}: ${socketError.message}`);
|
|
}
|
|
|
|
browser.interpreter.setRunId(data.runId);
|
|
|
|
const INTERPRETATION_TIMEOUT = 600000;
|
|
const interpretationPromise = browser.interpreter.InterpretRecording(
|
|
AddGeneratedFlags(recording.recording),
|
|
currentPage,
|
|
(newPage: Page) => currentPage = newPage,
|
|
plainRun.interpreterSettings,
|
|
);
|
|
|
|
const interpretationInfo = await Promise.race([
|
|
interpretationPromise,
|
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`Workflow interpretation timed out after ${INTERPRETATION_TIMEOUT / 1000}s`)), INTERPRETATION_TIMEOUT))
|
|
]);
|
|
|
|
if (await isRunAborted()) {
|
|
logger.log('info', `Run ${data.runId} was aborted during execution, not updating status`);
|
|
try { await browser.interpreter.clearState(); } catch (_) {}
|
|
await destroyRemoteBrowser(plainRun.browserId, data.userId);
|
|
return;
|
|
}
|
|
|
|
logger.log('info', `Workflow execution completed for run ${data.runId}`);
|
|
|
|
const finalRun = await Run.findByPk(run.id);
|
|
const categorizedOutput = {
|
|
scrapeSchema: finalRun?.serializableOutput?.scrapeSchema || {},
|
|
scrapeList: finalRun?.serializableOutput?.scrapeList || {},
|
|
crawl: finalRun?.serializableOutput?.crawl || {},
|
|
search: finalRun?.serializableOutput?.search || {}
|
|
};
|
|
|
|
let binaryOutput: Record<string, any> = { ...(interpretationInfo.binaryOutput || {}) };
|
|
|
|
const robotType = recording.recording_meta.type;
|
|
const outputFormats = (recording.recording_meta as any).formats as string[] | undefined;
|
|
if (robotType === 'crawl' || robotType === 'search') {
|
|
const processedOutput = await processRobotOutputFormats({
|
|
robotType,
|
|
outputFormats,
|
|
categorizedOutput: { crawl: categorizedOutput.crawl as Record<string, any>, search: categorizedOutput.search as Record<string, any> },
|
|
currentPage,
|
|
initialBinaryOutput: binaryOutput,
|
|
});
|
|
|
|
categorizedOutput.crawl = processedOutput.categorizedOutput.crawl;
|
|
categorizedOutput.search = processedOutput.categorizedOutput.search;
|
|
binaryOutput = processedOutput.binaryOutput;
|
|
|
|
const hasOutput = hasExpectedRobotOutput(robotType, { crawl: categorizedOutput.crawl as Record<string, any>, search: categorizedOutput.search as Record<string, any> }, outputFormats, binaryOutput);
|
|
if (!hasOutput) {
|
|
const humanRobotType = robotType.charAt(0).toUpperCase() + robotType.slice(1);
|
|
throw new Error(getInterpretationFailureReason(interpretationInfo.log, `${humanRobotType} run completed without producing output data`));
|
|
}
|
|
}
|
|
|
|
const binarySvc = new BinaryOutputService('maxun-run-screenshots');
|
|
const uploadedBinaryOutput = Object.keys(binaryOutput).length > 0 ? await binarySvc.uploadAndStoreBinaryOutput(run, binaryOutput) : {};
|
|
|
|
if (await isRunAborted()) {
|
|
logger.log('info', `Run ${data.runId} was aborted while processing results, not updating status`);
|
|
return;
|
|
}
|
|
|
|
await run.update({
|
|
status: 'success',
|
|
finishedAt: new Date().toLocaleString(),
|
|
log: interpretationInfo.log.join('\n'),
|
|
binaryOutput: uploadedBinaryOutput,
|
|
serializableOutput: { ...(finalRun?.serializableOutput || {}), crawl: categorizedOutput.crawl, search: categorizedOutput.search }
|
|
});
|
|
|
|
let totalSchemaItemsExtracted = 0;
|
|
let totalListItemsExtracted = 0;
|
|
if (categorizedOutput.scrapeSchema) Object.values(categorizedOutput.scrapeSchema).forEach((v: any) => { totalSchemaItemsExtracted += Array.isArray(v) ? v.length : (v ? 1 : 0); });
|
|
if (categorizedOutput.scrapeList) Object.values(categorizedOutput.scrapeList).forEach((v: any) => { totalListItemsExtracted += Array.isArray(v) ? v.length : 0; });
|
|
|
|
capture('maxun-oss-run-created', { runId: data.runId, user_id: data.userId, created_at: new Date().toISOString(), status: 'success', totalRowsExtracted: totalSchemaItemsExtracted + totalListItemsExtracted, schemaItemsExtracted: totalSchemaItemsExtracted, listItemsExtracted: totalListItemsExtracted, extractedScreenshotsCount: Object.keys(uploadedBinaryOutput).length, is_llm: (recording.recording_meta as any).isLLM, source: 'manual' });
|
|
|
|
try {
|
|
const completionData = { runId: data.runId, robotMetaId: plainRun.robotMetaId, robotName: recording.recording_meta.name, status: 'success', finishedAt: new Date().toLocaleString() };
|
|
serverIo.of(browserId).emit('run-completed', completionData);
|
|
serverIo.of('/queued-run').to(`user-${data.userId}`).emit('run-completed', completionData);
|
|
} catch (socketError: any) {
|
|
logger.log('warn', `Failed to send run-completed notification for run ${data.runId}: ${socketError.message}`);
|
|
}
|
|
|
|
try {
|
|
await sendWebhook(plainRun.robotMetaId, 'run_completed', {
|
|
robot_id: plainRun.robotMetaId, run_id: data.runId, robot_name: recording.recording_meta.name, status: 'success',
|
|
started_at: plainRun.startedAt, finished_at: new Date().toLocaleString(),
|
|
extracted_data: {
|
|
captured_texts: Object.keys(categorizedOutput.scrapeSchema || {}).length > 0 ? Object.entries(categorizedOutput.scrapeSchema).reduce((acc, [name, value]) => { acc[name] = Array.isArray(value) ? value : [value]; return acc; }, {} as Record<string, any[]>) : {},
|
|
captured_lists: categorizedOutput.scrapeList,
|
|
crawl_data: categorizedOutput.crawl, search_data: categorizedOutput.search,
|
|
captured_texts_count: totalSchemaItemsExtracted, captured_lists_count: totalListItemsExtracted, screenshots_count: Object.keys(uploadedBinaryOutput).length
|
|
},
|
|
metadata: { browser_id: plainRun.browserId, user_id: data.userId }
|
|
});
|
|
} catch (webhookError: any) {
|
|
logger.log('error', `Failed to send webhooks for run ${data.runId}: ${webhookError.message}`);
|
|
}
|
|
|
|
await triggerIntegrationUpdates(plainRun.runId, plainRun.robotMetaId);
|
|
await destroyRemoteBrowser(browserId, data.userId);
|
|
logger.log('info', `Browser ${browserId} destroyed after successful run ${data.runId}`);
|
|
|
|
} catch (executionError: any) {
|
|
logger.log('error', `Run execution failed for run ${data.runId}: ${executionError.message}`);
|
|
|
|
const browserId = data.browserId || (run as any).toJSON().browserId;
|
|
let partialDataExtracted = false;
|
|
|
|
try {
|
|
const hasData = (run.serializableOutput && (
|
|
(run.serializableOutput.scrapeSchema && run.serializableOutput.scrapeSchema.length > 0) ||
|
|
(run.serializableOutput.scrapeList && run.serializableOutput.scrapeList.length > 0) ||
|
|
(run.serializableOutput.crawl && Object.keys(run.serializableOutput.crawl).length > 0) ||
|
|
(run.serializableOutput.search && Object.keys(run.serializableOutput.search).length > 0))) ||
|
|
(run.binaryOutput && Object.keys(run.binaryOutput).length > 0);
|
|
if (hasData) {
|
|
await triggerIntegrationUpdates((run as any).toJSON().runId, (run as any).toJSON().robotMetaId);
|
|
partialDataExtracted = true;
|
|
}
|
|
} catch (_) {}
|
|
|
|
await run.update({ status: 'failed', finishedAt: new Date().toLocaleString(), log: `Failed: ${executionError.message}` });
|
|
|
|
const plainRun = (run as any).toJSON();
|
|
const recording = await Robot.findOne({ where: { 'recording_meta.id': run.robotMetaId }, raw: true });
|
|
|
|
try {
|
|
const failureData = { runId: data.runId, robotMetaId: plainRun.robotMetaId, robotName: recording ? recording.recording_meta.name : 'Unknown Robot', status: 'failed', finishedAt: new Date().toLocaleString(), hasPartialData: partialDataExtracted };
|
|
serverIo.of(browserId).emit('run-completed', failureData);
|
|
serverIo.of('/queued-run').to(`user-${data.userId}`).emit('run-completed', failureData);
|
|
} catch (_) {}
|
|
|
|
try {
|
|
await sendWebhook(plainRun.robotMetaId, 'run_failed', {
|
|
robot_id: plainRun.robotMetaId, run_id: data.runId, robot_name: recording ? recording.recording_meta.name : 'Unknown Robot',
|
|
status: 'failed', started_at: plainRun.startedAt, finished_at: new Date().toLocaleString(),
|
|
error: { message: executionError.message, stack: executionError.stack, type: 'ExecutionError' },
|
|
partial_data_extracted: partialDataExtracted, metadata: { browser_id: plainRun.browserId, user_id: data.userId }
|
|
});
|
|
} catch (_) {}
|
|
|
|
capture('maxun-oss-run-created', { runId: data.runId, user_id: data.userId, created_at: new Date().toISOString(), status: 'failed', error_message: executionError.message, partial_data_extracted: partialDataExtracted, source: 'manual' });
|
|
|
|
try { if (browser && browser.interpreter) await browser.interpreter.clearState(); } catch (_) {}
|
|
await destroyRemoteBrowser(browserId, data.userId);
|
|
}
|
|
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
logger.log('error', `Failed to process run execution job: ${errorMessage}`);
|
|
|
|
try {
|
|
const run = await Run.findOne({ where: { runId: data.runId } });
|
|
if (run) {
|
|
await run.update({ status: 'failed', finishedAt: new Date().toLocaleString(), log: `Failed: ${errorMessage}` });
|
|
const recording = await Robot.findOne({ where: { 'recording_meta.id': run.robotMetaId }, raw: true });
|
|
try {
|
|
await sendWebhook(run.robotMetaId, 'run_failed', {
|
|
robot_id: run.robotMetaId, run_id: data.runId, robot_name: recording ? recording.recording_meta.name : 'Unknown Robot',
|
|
status: 'failed', started_at: run.startedAt, finished_at: new Date().toLocaleString(),
|
|
error: { message: errorMessage }, metadata: { browser_id: run.browserId, user_id: data.userId }
|
|
});
|
|
} catch (_) {}
|
|
try {
|
|
serverIo.of(run.browserId).emit('run-completed', { runId: data.runId, robotMetaId: run.robotMetaId, robotName: recording ? recording.recording_meta.name : 'Unknown Robot', status: 'failed', finishedAt: new Date().toLocaleString() });
|
|
serverIo.of('/queued-run').to(`user-${data.userId}`).emit('run-completed', { runId: data.runId, status: 'failed' });
|
|
} catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function abortRun(runId: string, userId: string): Promise<void> {
|
|
try {
|
|
const run = await Run.findOne({ where: { runId } });
|
|
if (!run) {
|
|
logger.log('warn', `Run ${runId} not found`);
|
|
return;
|
|
}
|
|
|
|
await run.update({ status: 'aborting' });
|
|
|
|
const plainRun = run.toJSON();
|
|
const recording = await Robot.findOne({ where: { 'recording_meta.id': plainRun.robotMetaId }, raw: true });
|
|
const robotName = recording?.recording_meta?.name || 'Unknown Robot';
|
|
|
|
let browser;
|
|
try { browser = browserPool.getRemoteBrowser(plainRun.browserId); } catch { browser = null; }
|
|
|
|
if (!browser) {
|
|
await run.update({ status: 'aborted', finishedAt: new Date().toLocaleString(), log: 'Aborted: Browser not found or already closed' });
|
|
try { serverIo.of(plainRun.browserId).emit('run-aborted', { runId, robotName, status: 'aborted', finishedAt: new Date().toLocaleString() }); } catch (_) {}
|
|
return;
|
|
}
|
|
|
|
await run.update({ status: 'aborted', finishedAt: new Date().toLocaleString(), log: 'Run aborted by user' });
|
|
|
|
const hasData = (run.serializableOutput && (
|
|
(run.serializableOutput.scrapeSchema && run.serializableOutput.scrapeSchema.length > 0) ||
|
|
(run.serializableOutput.scrapeList && run.serializableOutput.scrapeList.length > 0))) ||
|
|
(run.binaryOutput && Object.keys(run.binaryOutput).length > 0);
|
|
|
|
if (hasData) await triggerIntegrationUpdates(runId, plainRun.robotMetaId);
|
|
|
|
try { serverIo.of(plainRun.browserId).emit('run-aborted', { runId, robotName, status: 'aborted', finishedAt: new Date().toLocaleString() }); } catch (_) {}
|
|
|
|
try {
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
await destroyRemoteBrowser(plainRun.browserId, userId);
|
|
} catch (cleanupError) {
|
|
logger.log('warn', `Failed to clean up browser for aborted run ${runId}: ${cleanupError}`);
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
logger.log('error', `Failed to abort run ${runId}: ${errorMessage}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const taskList: TaskList = {
|
|
[QUEUE_NAMES.INITIALIZE_BROWSER_RECORDING]: async (payload: unknown) => {
|
|
const data = payload as InitializeBrowserData;
|
|
logger.log('info', `Starting browser initialization job for user: ${data.userId}`);
|
|
initializeRemoteBrowserForRecording(data.userId);
|
|
logger.log('info', `Browser initialization triggered for user: ${data.userId}`);
|
|
},
|
|
|
|
[QUEUE_NAMES.DESTROY_BROWSER]: async (payload: unknown) => {
|
|
const data = payload as DestroyBrowserData;
|
|
logger.log('info', `Starting browser destruction job for browser: ${data.browserId}`);
|
|
await destroyRemoteBrowser(data.browserId, data.userId);
|
|
logger.log('info', `Browser ${data.browserId} destroyed`);
|
|
},
|
|
|
|
[QUEUE_NAMES.INTERPRET_WORKFLOW]: async (payload: unknown) => {
|
|
const data = payload as InterpretWorkflow;
|
|
logger.log('info', 'Starting workflow interpretation job');
|
|
await interpretWholeWorkflow(data.userId);
|
|
logger.log('info', 'Workflow interpretation job completed');
|
|
},
|
|
|
|
[QUEUE_NAMES.STOP_INTERPRETATION]: async (payload: unknown) => {
|
|
const data = payload as StopInterpretWorkflow;
|
|
logger.log('info', 'Starting stop interpretation job');
|
|
await stopRunningInterpretation(data.userId);
|
|
logger.log('info', 'Stop interpretation job completed');
|
|
},
|
|
|
|
[QUEUE_NAMES.EXECUTE_RUN]: async (payload: unknown) => {
|
|
await processRunExecution(payload as ExecuteRunData);
|
|
},
|
|
|
|
[QUEUE_NAMES.ABORT_RUN]: async (payload: unknown) => {
|
|
const data = payload as AbortRunData;
|
|
logger.log('info', `Processing abort request for run ${data.runId} by user ${data.userId}`);
|
|
await abortRun(data.runId, data.userId);
|
|
},
|
|
|
|
[QUEUE_NAMES.SCHEDULED_WORKFLOW]: async (payload: unknown) => {
|
|
const data = payload as ScheduledWorkflowData;
|
|
logger.log('info', `Processing scheduled workflow for robot ${data.robotMetaId}`);
|
|
await handleRunRecording(data.robotMetaId, data.userId);
|
|
},
|
|
};
|
|
|
|
let runner: Runner | null = null;
|
|
let runnerPool: Pool | null = null;
|
|
let workersStarted = false;
|
|
|
|
export async function startWorkers(): Promise<void> {
|
|
if (workersStarted) {
|
|
logger.log('warn', 'Graphile Worker runner already started, skipping...');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
logger.log('info', 'Starting Graphile Worker runner...');
|
|
|
|
runnerPool = new Pool({
|
|
connectionString,
|
|
max: TOTAL_CONCURRENCY + 2,
|
|
ssl: useSSL ? true : undefined,
|
|
});
|
|
|
|
runner = await run({
|
|
pgPool: runnerPool,
|
|
concurrency: TOTAL_CONCURRENCY,
|
|
noHandleSignals: true,
|
|
pollInterval: 3600000,
|
|
taskList,
|
|
});
|
|
|
|
runner.events.on('job:error', ({ job, error }: { job: any; error: unknown }) => {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
logger.log('error', `Job error for job ${job?.id} (${job?.task_identifier}): ${msg}`);
|
|
});
|
|
|
|
workersStarted = true;
|
|
logger.log('info', `Graphile Worker runner started (concurrency: ${TOTAL_CONCURRENCY})`);
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
logger.log('error', `Failed to start Graphile Worker runner: ${errorMessage}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function stopWorkers(): Promise<void> {
|
|
if (!workersStarted || !runner) return;
|
|
|
|
try {
|
|
await runner.stop();
|
|
runner = null;
|
|
} catch (error: any) {
|
|
logger.log('warn', `Error stopping Graphile Worker runner: ${error.message}`);
|
|
}
|
|
|
|
try {
|
|
if (runnerPool) {
|
|
await runnerPool.end();
|
|
runnerPool = null;
|
|
}
|
|
} catch (error: any) {
|
|
logger.log('warn', `Error closing runner pool: ${error.message}`);
|
|
}
|
|
|
|
workersStarted = false;
|
|
logger.log('info', 'Graphile Worker runner stopped');
|
|
}
|