chore: 重构回放引擎

This commit is contained in:
hangerye
2025-10-27 10:05:44 +08:00
parent 73d205bfec
commit e460ebdc03
35 changed files with 2280 additions and 1794 deletions
@@ -0,0 +1,69 @@
// engine/logging/run-logger.ts — run logs, overlay and persistence
import type { RunLogEntry, RunRecord, Flow } from '../../types';
import { appendRun } from '../../flow-store';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
export class RunLogger {
private logs: RunLogEntry[] = [];
constructor(private runId: string) {}
push(e: RunLogEntry) {
this.logs.push(e);
}
getLogs() {
return this.logs;
}
async overlayInit() {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (tabs[0]?.id)
await chrome.tabs.sendMessage(tabs[0].id, { action: 'rr_overlay', cmd: 'init' } as any);
} catch {}
}
async overlayAppend(text: string) {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (tabs[0]?.id)
await chrome.tabs.sendMessage(tabs[0].id, {
action: 'rr_overlay',
cmd: 'append',
text,
} as any);
} catch {}
}
async overlayDone() {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (tabs[0]?.id)
await chrome.tabs.sendMessage(tabs[0].id, { action: 'rr_overlay', cmd: 'done' } as any);
} catch {}
}
async screenshotOnFailure() {
try {
const shot = await handleCallTool({
name: TOOL_NAMES.BROWSER.COMPUTER,
args: { action: 'screenshot' },
});
const img = (shot?.content?.find((c: any) => c.type === 'image') as any)?.data as string;
if (img) this.logs[this.logs.length - 1].screenshotBase64 = img;
} catch {}
}
async persist(flow: Flow, startedAt: number, success: boolean) {
const record: RunRecord = {
id: this.runId,
flowId: flow.id,
startedAt: new Date(startedAt).toISOString(),
finishedAt: new Date().toISOString(),
success,
entries: this.logs,
};
await appendRun(record);
}
}
@@ -0,0 +1,19 @@
import type { RunPlugin, StepContext } from './types';
import { runState } from '../state-manager';
export function breakpointPlugin(): RunPlugin {
return {
name: 'breakpoint',
async onBeforeStep(ctx: StepContext) {
try {
const step: any = ctx.step as any;
const hasBreakpoint = step?.$breakpoint === true || step?.breakpoint === true;
if (!hasBreakpoint) return;
// mark run paused for external UI to resume
await runState.update(ctx.runId, { status: 'stopped', updatedAt: Date.now() } as any);
return { pause: true };
} catch {}
return;
},
};
}
@@ -0,0 +1,74 @@
import type {
RunPlugin,
HookControl,
RunContext,
StepContext,
StepAfterContext,
StepErrorContext,
StepRetryContext,
RunEndContext,
SubflowContext,
} from './types';
export class PluginManager {
constructor(private plugins: RunPlugin[]) {}
async runStart(ctx: RunContext) {
for (const p of this.plugins) await safeCall(p, 'onRunStart', ctx);
}
async beforeStep(ctx: StepContext): Promise<HookControl | undefined> {
for (const p of this.plugins) {
const out = await safeCall(p, 'onBeforeStep', ctx);
if (out && (out.pause || out.nextLabel)) return out;
}
return undefined;
}
async afterStep(ctx: StepAfterContext) {
for (const p of this.plugins) await safeCall(p, 'onAfterStep', ctx);
}
async onError(ctx: StepErrorContext): Promise<HookControl | undefined> {
for (const p of this.plugins) {
const out = await safeCall(p, 'onStepError', ctx);
if (out && (out.pause || out.nextLabel)) return out;
}
return undefined;
}
async onRetry(ctx: StepRetryContext) {
for (const p of this.plugins) await safeCall(p, 'onRetry', ctx);
}
async onChooseNextLabel(ctx: StepContext & { suggested?: string }): Promise<string | undefined> {
for (const p of this.plugins) {
const out = await safeCall(p, 'onChooseNextLabel', ctx);
if (out && out.nextLabel) return String(out.nextLabel);
}
return undefined;
}
async subflowStart(ctx: SubflowContext) {
for (const p of this.plugins) await safeCall(p, 'onSubflowStart', ctx);
}
async subflowEnd(ctx: SubflowContext) {
for (const p of this.plugins) await safeCall(p, 'onSubflowEnd', ctx);
}
async runEnd(ctx: RunEndContext) {
for (const p of this.plugins) await safeCall(p, 'onRunEnd', ctx);
}
}
async function safeCall<T extends keyof RunPlugin>(plugin: RunPlugin, key: T, arg: any) {
try {
const fn = plugin[key] as any;
if (typeof fn === 'function') return await fn.call(plugin, arg);
} catch (e) {
// swallow plugin errors to keep core stable
// console.warn(`[plugin:${plugin.name}] ${String(key)} error:`, e);
}
return undefined;
}
@@ -0,0 +1,56 @@
// Plugin system for record-replay engine
// Inspired by webpack-like lifecycle hooks, to avoid touching core for extensibility
import type { Flow, Step } from '../../types';
import type { ExecResult } from '../../nodes';
export interface RunContext {
runId: string;
flow: Flow;
vars: Record<string, any>;
}
export interface StepContext extends RunContext {
step: Step;
}
export interface StepErrorContext extends StepContext {
error: any;
}
export interface StepRetryContext extends StepErrorContext {
attempt: number;
}
export interface StepAfterContext extends StepContext {
result?: ExecResult;
}
export interface SubflowContext extends RunContext {
subflowId: string;
}
export interface RunEndContext extends RunContext {
success: boolean;
failed: number;
}
export interface HookControl {
pause?: boolean; // request scheduler to pause run (e.g., breakpoint)
nextLabel?: string; // override next edge label
}
export interface RunPlugin {
name: string;
onRunStart?(ctx: RunContext): Promise<void> | void;
onBeforeStep?(ctx: StepContext): Promise<HookControl | void> | HookControl | void;
onAfterStep?(ctx: StepAfterContext): Promise<void> | void;
onStepError?(ctx: StepErrorContext): Promise<HookControl | void> | HookControl | void;
onRetry?(ctx: StepRetryContext): Promise<void> | void;
onChooseNextLabel?(
ctx: StepContext & { suggested?: string },
): Promise<HookControl | void> | HookControl | void;
onSubflowStart?(ctx: SubflowContext): Promise<void> | void;
onSubflowEnd?(ctx: SubflowContext): Promise<void> | void;
onRunEnd?(ctx: RunEndContext): Promise<void> | void;
}
@@ -0,0 +1,31 @@
// engine/policies/retry.ts — unified retry/backoff policy
export type BackoffKind = 'none' | 'exp';
export interface RetryOptions {
count?: number; // max attempts beyond the first run
intervalMs?: number;
backoff?: BackoffKind;
}
export async function withRetry<T>(
run: () => Promise<T>,
onRetry?: (attempt: number, err: any) => Promise<void> | void,
opts?: RetryOptions,
): Promise<T> {
const max = Math.max(0, Number(opts?.count ?? 0));
const base = Math.max(0, Number(opts?.intervalMs ?? 0));
const backoff = (opts?.backoff || 'none') as BackoffKind;
let attempt = 0;
while (true) {
try {
return await run();
} catch (e) {
if (attempt >= max) throw e;
if (onRetry) await onRetry(attempt, e);
const delay = base > 0 ? (backoff === 'exp' ? base * Math.pow(2, attempt) : base) : 0;
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
attempt += 1;
}
}
}
@@ -0,0 +1,94 @@
// engine/policies/wait.ts — wrappers around rr-utils navigation/network waits
// Keep logic centralized to avoid duplication in schedulers and nodes
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { waitForNavigation as rrWaitForNavigation, waitForNetworkIdle } from '../../rr-utils';
export async function waitForNavigationDone(prevUrl: string, timeoutMs?: number) {
await rrWaitForNavigation(timeoutMs, prevUrl);
}
export async function ensureReadPageIfWeb() {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const url = tabs?.[0]?.url || '';
if (/^(https?:|file:)/i.test(url)) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
}
} catch {}
}
export async function maybeQuickWaitForNav(prevUrl: string, timeoutMs?: number) {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') return;
const sniffMs = 350;
const startedAt = Date.now();
let seen = false;
await new Promise<void>((resolve) => {
let timer: any = null;
const cleanup = () => {
try {
chrome.webNavigation.onCommitted.removeListener(onCommitted);
} catch {}
try {
chrome.webNavigation.onCompleted.removeListener(onCompleted);
} catch {}
try {
(chrome.webNavigation as any).onHistoryStateUpdated?.removeListener?.(
onHistoryStateUpdated,
);
} catch {}
try {
chrome.tabs.onUpdated.removeListener(onUpdated);
} catch {}
if (timer) {
try {
clearTimeout(timer);
} catch {}
}
};
const finish = async () => {
cleanup();
if (seen) {
try {
await rrWaitForNavigation(
prevUrl ? Math.min(timeoutMs || 15000, 30000) : undefined,
prevUrl,
);
} catch {}
}
resolve();
};
const mark = () => {
seen = true;
};
const onCommitted = (d: any) => {
if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();
};
const onCompleted = (d: any) => {
if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();
};
const onHistoryStateUpdated = (d: any) => {
if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();
};
const onUpdated = (updatedId: number, change: chrome.tabs.TabChangeInfo) => {
if (updatedId !== tabId) return;
if (change.status === 'loading') mark();
if (typeof change.url === 'string' && (!prevUrl || change.url !== prevUrl)) mark();
};
chrome.webNavigation.onCommitted.addListener(onCommitted);
chrome.webNavigation.onCompleted.addListener(onCompleted);
try {
(chrome.webNavigation as any).onHistoryStateUpdated?.addListener?.(onHistoryStateUpdated);
} catch {}
chrome.tabs.onUpdated.addListener(onUpdated);
timer = setTimeout(finish, sniffMs);
});
} catch {}
}
export { waitForNetworkIdle };
@@ -0,0 +1,538 @@
// engine/scheduler.ts — DAG-only orchestrator for record-replay
// Note: consolidates wait/retry/logging and delegates node execution to nodes/* registry
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
import type { Flow, RunLogEntry, RunResult, Step, StepScript } from '../types';
import {
mapDagNodeToStep,
topoOrder,
ensureTab,
expandTemplatesDeep,
waitForNetworkIdle,
applyAssign,
defaultEdgesOnly,
} from '../rr-utils';
import { executeStep, type ExecCtx } from '../nodes';
import { RunLogger } from './logging/run-logger';
import { PluginManager } from './plugins/manager';
import type { RunPlugin } from './plugins/types';
import { breakpointPlugin } from './plugins/breakpoint';
import { waitForNavigationDone, maybeQuickWaitForNav, ensureReadPageIfWeb } from './policies/wait';
import { withRetry } from './policies/retry';
import { runState } from './state-manager';
export interface RunOptions {
tabTarget?: 'current' | 'new';
refresh?: boolean;
captureNetwork?: boolean;
returnLogs?: boolean;
timeoutMs?: number;
startUrl?: string;
args?: Record<string, any>;
startNodeId?: string;
plugins?: RunPlugin[];
}
export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<RunResult> {
const runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const startAt = Date.now();
const logger = new RunLogger(runId);
const pluginManager = new PluginManager(
options.plugins && options.plugins.length ? options.plugins : [breakpointPlugin()],
);
// Global deadline for run (optional)
const globalTimeout = Math.max(0, Number(options.timeoutMs || 0));
const deadline = globalTimeout > 0 ? startAt + globalTimeout : 0;
const ensureWithinDeadline = () => {
if (deadline > 0 && Date.now() > deadline) {
const err = new Error('Global timeout reached');
logger.push({
stepId: 'global-timeout',
status: 'failed',
message: 'Global timeout reached',
});
throw err;
}
};
// prepare variables
const vars: Record<string, any> = Object.create(null);
for (const v of flow.variables || []) if (v.default !== undefined) vars[v.key] = v.default;
if (options.args) Object.assign(vars, options.args);
// Derive a default startUrl when not provided: prefer first navigate step
let derivedStartUrl: string | undefined = undefined;
try {
const hasDag0 = Array.isArray((flow as any).nodes) && (flow as any).nodes.length > 0;
const nodes0 = hasDag0 ? (((flow as any).nodes || []) as any[]) : [];
const edges0 = hasDag0 ? (((flow as any).edges || []) as any[]) : [];
const defaultEdges0 = hasDag0 ? defaultEdgesOnly(edges0 as any) : [];
const order0 = hasDag0 ? topoOrder(nodes0 as any, defaultEdges0 as any) : [];
const steps0: Step[] = hasDag0 ? order0.map((n) => mapDagNodeToStep(n as any)) : [];
const nav = steps0.find((s: any) => s && (s as any).type === 'navigate') as any;
if (nav && typeof nav.url === 'string') derivedStartUrl = expandTemplatesDeep(nav.url, {});
} catch {}
const ensured = await ensureTab({
tabTarget: options.tabTarget,
startUrl: options.startUrl || derivedStartUrl,
refresh: options.refresh,
});
// register run state
try {
await runState.restore();
await runState.add(runId, {
id: runId,
flowId: flow.id,
name: flow.name,
status: 'running',
startedAt: startAt,
updatedAt: startAt,
});
} catch {}
// plugins: run start
await pluginManager.runStart({ runId, flow, vars });
// pre-load read_page to init bridges only when on a web page
try {
const u = ensured?.url || '';
if (/^(https?:|file:)/i.test(u)) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
}
} catch {}
// collect required variables via overlay prompt
try {
const needed = (flow.variables || []).filter(
(v) =>
(options.args?.[v.key] == null || options.args?.[v.key] === '') &&
(v.rules?.required || (v.default ?? '') === ''),
);
if (needed.length) {
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT,
args: {
eventName: 'collectVariables',
payload: JSON.stringify({ variables: needed, useOverlay: true }),
},
});
let values: Record<string, any> | null = null;
try {
const t = (res?.content || []).find((c: any) => c.type === 'text')?.text;
const j = t ? JSON.parse(t) : null;
if (j && j.success && j.values) values = j.values;
} catch {}
if (!values) {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId === 'number') {
const res2 = await chrome.tabs.sendMessage(tabId, {
action: 'collectVariables',
variables: needed,
useOverlay: true,
} as any);
if (res2 && res2.success && res2.values) values = res2.values;
}
}
if (values) Object.assign(vars, values);
}
} catch {}
await logger.overlayInit();
// binding enforcement
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const currentUrl = tabs?.[0]?.url || '';
const bindings = flow.meta?.bindings || [];
if (!options.startUrl && bindings.length > 0) {
const ok = bindings.some((b) => {
try {
if (b.type === 'domain') return new URL(currentUrl).hostname.includes(b.value);
if (b.type === 'path') return new URL(currentUrl).pathname.startsWith(b.value);
if (b.type === 'url') return currentUrl.startsWith(b.value);
} catch {}
return false;
});
if (!ok) {
return {
runId,
success: false,
summary: { total: 0, success: 0, failed: 0, tookMs: 0 },
url: currentUrl,
outputs: null,
logs: [
{
stepId: 'binding-check',
status: 'failed',
message:
'Flow binding mismatch. Provide startUrl or open a page matching flow.meta.bindings.',
},
],
screenshots: { onFailure: null },
} as RunResult;
}
}
} catch {}
// long-running network capture (debugger) if requested
let networkCaptureStarted = false;
const stopAndSummarizeNetwork = async () => {
try {
const stopRes = await handleCallTool({
name: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP,
args: {},
});
const text = (stopRes?.content || []).find((c: any) => c.type === 'text')?.text;
if (!text) return;
const data = JSON.parse(text);
const requests: any[] = Array.isArray(data?.requests) ? data.requests : [];
const snippets = requests
.filter((r) => ['XHR', 'Fetch'].includes(String(r.type)))
.slice(0, 10)
.map((r) => ({
method: String(r.method || 'GET'),
url: String(r.url || ''),
status: r.statusCode || r.status,
ms: Math.max(0, (r.responseTime || 0) - (r.requestTime || 0)),
}));
logger.push({
stepId: 'network-capture',
status: 'success',
message: `Captured ${Number(data?.requestCount || 0)} requests` as any,
networkSnippets: snippets,
} as any);
} catch {}
};
if (options.captureNetwork) {
try {
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START,
args: { includeStatic: false, maxCaptureTime: 3 * 60_000, inactivityTimeout: 0 },
});
if (!(res as any)?.isError) networkCaptureStarted = true;
} catch {}
}
// DAG required
const hasDag = Array.isArray((flow as any).nodes) && (flow as any).nodes.length > 0;
if (!hasDag) {
await logger.overlayDone();
return {
runId,
success: false,
summary: { total: 0, success: 0, failed: 0, tookMs: 0 },
url: null,
outputs: null,
logs: [
{
stepId: 'dag-required',
status: 'failed',
message:
'Flow has no DAG nodes. Linear steps are no longer supported. Please migrate this flow to nodes/edges.',
},
],
screenshots: { onFailure: null },
} as RunResult;
}
const nodes = ((flow as any).nodes || []) as any[];
const edges = ((flow as any).edges || []) as any[];
const defaultEdges = defaultEdgesOnly(edges as any);
const order = topoOrder(nodes as any, defaultEdges as any);
const stepsToRun: Step[] = order.map((n) => mapDagNodeToStep(n as any));
const steps = stepsToRun;
let failed = 0;
let paused = false;
const ctx: ExecCtx = { vars, logger: (e: RunLogEntry) => logger.push(e) };
const pendingAfterScripts: StepScript[] = [];
const appendOverlayOk = (s: Step) => logger.overlayAppend(`✔ ${s.type} (${s.id})`);
const appendOverlayFail = (s: Step, err: any) =>
logger.overlayAppend(`✘ ${s.type} (${s.id}) -> ${err?.message || String(err)}`);
const evalCondition = (cond: any): boolean => {
try {
if (cond && typeof cond.expression === 'string' && cond.expression.trim()) {
const fn = new Function(
'vars',
`try { return !!(${cond.expression}); } catch (e) { return false; }`,
);
return !!fn(vars);
}
if (cond && typeof cond.var === 'string') {
const v = vars[cond.var];
if ('equals' in cond) return String(v) === String(cond.equals);
return !!v;
}
} catch {}
return false;
};
const runSubflowById = async (subflowId: string) => {
const sub = (flow.subflows || {})[subflowId];
if (!sub || !Array.isArray(sub.nodes) || sub.nodes.length === 0) return;
await pluginManager.subflowStart({ runId, flow, vars, subflowId });
const sNodes: any[] = sub.nodes;
const sEdges: any[] = defaultEdgesOnly((sub.edges || []) as any) as any[];
const sOrder = topoOrder(sNodes as any, sEdges as any);
const sSteps: Step[] = sOrder.map((n) => mapDagNodeToStep(n as any)) as any;
for (const step of sSteps) {
const t0 = Date.now();
ensureWithinDeadline();
const ctrl = await pluginManager.beforeStep({ runId, flow, vars, step });
if (ctrl?.pause) {
paused = true;
break;
}
await withRetry(
async () => {
const beforeInfo = await getActiveTabInfo();
const result = await executeStep(ctx, step);
if (step.type === 'click' || step.type === 'dblclick') {
const after = ((step as any).after || {}) as any;
if (after.waitForNavigation)
await waitForNavigationDone(beforeInfo.url, (step as any).timeoutMs);
else if (after.waitForNetworkIdle)
await waitForNetworkIdle(Math.min((step as any).timeoutMs || 5000, 120000), 1200);
else await maybeQuickWaitForNav(beforeInfo.url, (step as any).timeoutMs);
}
if (step.type === 'navigate' || step.type === 'openTab') {
await waitForNavigationDone(beforeInfo.url, (step as any).timeoutMs);
await ensureReadPageIfWeb();
} else if (step.type === 'switchTab') {
await ensureReadPageIfWeb();
}
if (!result?.alreadyLogged)
logger.push({ stepId: step.id, status: 'success', tookMs: Date.now() - t0 });
await pluginManager.afterStep({ runId, flow, vars, step, result });
await appendOverlayOk(step);
if (result?.deferAfterScript) pendingAfterScripts.push(result.deferAfterScript);
await flushAfterScripts(ctx, pendingAfterScripts, vars, logger);
},
async (attempt, e) => {
logger.push({ stepId: step.id, status: 'retrying', message: e?.message || String(e) });
await pluginManager.onRetry({ runId, flow, vars, step, error: e, attempt });
},
{
count: Math.max(0, (step as any).retry?.count ?? 0),
intervalMs: Math.max(0, (step as any).retry?.intervalMs ?? 0),
backoff: (step as any).retry?.backoff || 'none',
},
);
if (paused) break;
}
await pluginManager.subflowEnd({ runId, flow, vars, subflowId });
};
try {
// DAG traversal (single-branch by nextLabel)
const id2node = new Map(nodes.map((n: any) => [n.id, n] as const));
const outEdges = new Map<string, Array<any>>();
for (const e of edges) {
if (!outEdges.has(e.from)) outEdges.set(e.from, []);
outEdges.get(e.from)!.push(e);
}
const indeg = new Map<string, number>(nodes.map((n: any) => [n.id, 0] as const));
for (const e of edges) indeg.set(e.to, (indeg.get(e.to) || 0) + 1);
let currentId =
options.startNodeId && id2node.has(options.startNodeId)
? options.startNodeId
: nodes.find((n: any) => (indeg.get(n.id) || 0) === 0)?.id || nodes[0]?.id;
let guard = 0;
while (currentId && guard++ < 10000) {
ensureWithinDeadline();
const node = id2node.get(currentId);
if (!node) break;
const step: any = mapDagNodeToStep(node as any);
const t0 = Date.now();
const beforeInfo = await getActiveTabInfo();
let jumpedOnError = false;
let stepNextLabel: string | undefined;
const ctrlStart = await pluginManager.beforeStep({ runId, flow, vars, step });
if (ctrlStart?.pause) {
paused = true;
break;
}
try {
await withRetry(
async () => {
const result = await executeStep(ctx, step);
if (step.type === 'click' || step.type === 'dblclick') {
const after = ((step as any).after || {}) as any;
if (after.waitForNavigation)
await waitForNavigationDone(beforeInfo.url, (step as any).timeoutMs);
else if (after.waitForNetworkIdle)
await waitForNetworkIdle(Math.min((step as any).timeoutMs || 5000, 120000), 1200);
else await maybeQuickWaitForNav(beforeInfo.url, (step as any).timeoutMs);
}
if (step.type === 'navigate' || step.type === 'openTab') {
await waitForNavigationDone(beforeInfo.url, (step as any).timeoutMs);
await ensureReadPageIfWeb();
} else if (step.type === 'switchTab') {
await ensureReadPageIfWeb();
}
if (!result?.alreadyLogged)
logger.push({ stepId: step.id, status: 'success', tookMs: Date.now() - t0 });
await pluginManager.afterStep({ runId, flow, vars, step, result });
await appendOverlayOk(step);
if (result?.nextLabel) stepNextLabel = String(result.nextLabel);
if (result?.control) {
if (result.control.kind === 'foreach') {
const list = Array.isArray(vars[result.control.listVar])
? (vars[result.control.listVar] as any[])
: [];
for (const it of list) {
vars[result.control.itemVar] = it;
await runSubflowById(result.control.subflowId);
}
} else if (result.control.kind === 'while') {
let i = 0;
while (
i < result.control.maxIterations &&
evalCondition(result.control.condition)
) {
await runSubflowById(result.control.subflowId);
i++;
}
}
}
if (result?.deferAfterScript) pendingAfterScripts.push(result.deferAfterScript);
await flushAfterScripts(ctx, pendingAfterScripts, vars, logger);
},
async (attempt, e) => {
logger.push({ stepId: step.id, status: 'retrying', message: e?.message || String(e) });
await pluginManager.onRetry({ runId, flow, vars, step, error: e, attempt });
},
{
count: Math.max(0, (step as any).retry?.count ?? 0),
intervalMs: Math.max(0, (step as any).retry?.intervalMs ?? 0),
backoff: (step as any).retry?.backoff || 'none',
},
);
} catch (e: any) {
failed++;
logger.push({
stepId: step.id,
status: 'failed',
message: e?.message || String(e),
tookMs: Date.now() - t0,
});
await appendOverlayFail(step, e);
if ((step as any).screenshotOnFail !== false) await logger.screenshotOnFailure();
const hook = await pluginManager.onError({ runId, flow, vars, step, error: e });
if (hook?.pause) {
paused = true;
break;
}
const oes = (outEdges.get(currentId) || []) as any[];
const errEdge = oes.find((edg) => edg.label === 'onError');
if (errEdge) {
currentId = errEdge.to;
jumpedOnError = true;
} else {
throw e;
}
}
if (paused) break;
if (!jumpedOnError) {
// choose next by label from success path
let nextLabel: string = stepNextLabel ? String(stepNextLabel) : 'default';
const override = await pluginManager.onChooseNextLabel({
runId,
flow,
vars,
step,
suggested: nextLabel,
});
if (override) nextLabel = String(override);
const oes = (outEdges.get(currentId) || []) as any[];
const edge =
oes.find((e) => String(e.label || 'default') === nextLabel) ||
oes.find((e) => !e.label || e.label === 'default');
currentId = edge ? edge.to : undefined;
}
}
} finally {
if (networkCaptureStarted) await stopAndSummarizeNetwork();
}
await logger.overlayDone();
const tookMs = Date.now() - startAt;
// outputs: filter sensitive variables
const sensitiveKeys = new Set(
(flow.variables || []).filter((v) => v.sensitive).map((v) => v.key),
);
const outputs: Record<string, any> = {};
for (const [k, v] of Object.entries(vars)) if (!sensitiveKeys.has(k)) outputs[k] = v;
await pluginManager.runEnd({ runId, flow, vars, success: failed === 0 && !paused, failed });
if (!paused) await logger.persist(flow, startAt, failed === 0);
try {
await runState.update(runId, {
status: paused ? 'stopped' : failed === 0 ? 'completed' : 'failed',
updatedAt: Date.now(),
} as any);
if (!paused) await runState.delete(runId);
} catch {}
return {
runId,
success: !paused && failed === 0,
summary: { total: steps.length, success: steps.length - failed, failed, tookMs },
url: null,
outputs,
logs: options.returnLogs ? logger.getLogs() : undefined,
screenshots: {
onFailure: logger.getLogs().find((l) => l.status === 'failed')?.screenshotBase64,
},
paused,
};
}
async function getActiveTabInfo() {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
return { url: tab?.url || '', status: (tab as any)?.status || '' };
}
async function flushAfterScripts(
ctx: ExecCtx,
pendingAfterScripts: StepScript[],
vars: Record<string, any>,
logger: RunLogger,
) {
if (pendingAfterScripts.length === 0) return;
while (pendingAfterScripts.length) {
const s = pendingAfterScripts.shift()!;
const tScript = Date.now();
const world = (s as any).world || 'ISOLATED';
const code = String((s as any).code || '');
if (code.trim()) {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (userCode: string) => {
try {
return (0, eval)(userCode);
} catch {
return null;
}
},
args: [code],
world: world as any,
} as any);
if ((s as any).saveAs) vars[(s as any).saveAs] = result;
if ((s as any).assign && typeof (s as any).assign === 'object')
applyAssign(vars, result, (s as any).assign);
}
logger.push({ stepId: s.id, status: 'success', tookMs: Date.now() - tScript });
}
}
@@ -0,0 +1,87 @@
// engine/state-manager.ts — lightweight run state store with events and persistence
type Listener<T> = (payload: T) => void;
export interface RunState {
id: string;
flowId: string;
name?: string;
status: 'running' | 'completed' | 'failed' | 'stopped';
startedAt: number;
updatedAt: number;
}
export class StateManager<T extends { id: string }> {
private key: string;
private states = new Map<string, T>();
private listeners: Record<string, Listener<any>[]> = Object.create(null);
constructor(storageKey: string) {
this.key = storageKey;
}
on<E = any>(name: string, listener: Listener<E>) {
(this.listeners[name] = this.listeners[name] || []).push(listener);
}
off<E = any>(name: string, listener: Listener<E>) {
const arr = this.listeners[name];
if (!arr) return;
const i = arr.indexOf(listener as any);
if (i >= 0) arr.splice(i, 1);
}
private emit<E = any>(name: string, payload: E) {
const arr = this.listeners[name] || [];
for (const fn of arr)
try {
fn(payload);
} catch {}
}
getAll(): Map<string, T> {
return this.states;
}
get(id: string): T | undefined {
return this.states.get(id);
}
async add(id: string, data: T): Promise<void> {
this.states.set(id, data);
this.emit('add', { id, data });
await this.persist();
}
async update(id: string, patch: Partial<T>): Promise<void> {
const cur = this.states.get(id);
if (!cur) return;
const next = Object.assign({}, cur, patch);
this.states.set(id, next);
this.emit('update', { id, data: next });
await this.persist();
}
async delete(id: string): Promise<void> {
this.states.delete(id);
this.emit('delete', { id });
await this.persist();
}
private async persist(): Promise<void> {
try {
const obj = Object.fromEntries(this.states.entries());
await chrome.storage.local.set({ [this.key]: obj });
} catch {}
}
async restore(): Promise<void> {
try {
const res = await chrome.storage.local.get(this.key);
const obj = (res && res[this.key]) || {};
this.states = new Map(Object.entries(obj) as any);
} catch {}
}
}
export const runState = new StateManager<RunState>('rr_run_states');
@@ -1,3 +1,3 @@
// thin re-export for backward compatibility
export { runFlow } from './runner';
export type { RunOptions } from './runner';
export { runFlow } from './engine/scheduler';
export type { RunOptions } from './engine/scheduler';
@@ -1,4 +1,4 @@
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
import { BACKGROUND_MESSAGE_TYPES, CONTENT_MESSAGE_TYPES } from '@/common/message-types';
import { Flow } from './types';
import {
listFlows,
@@ -281,6 +281,36 @@ export function initRecordReplayListeners() {
try {
if (details.frameId !== 0) return;
const url = details.url || '';
// Ensure core content scripts are injected for this tab (pre-heat for replay)
await ensureCoreInjected(details.tabId);
// Ensure DOM observer is active on this tab (if triggers exist)
try {
const { [STORAGE_KEYS.RR_TRIGGERS]: stored } =
(await chrome.storage.local.get(STORAGE_KEYS.RR_TRIGGERS)) || {};
const triggers: any[] = Array.isArray(stored) ? stored : [];
const domTriggers = triggers
.filter((x) => x.type === 'dom' && x.enabled !== false)
.map((x: any) => ({
id: x.id,
selector: x.selector,
appear: x.appear !== false,
once: x.once !== false,
debounceMs: x.debounceMs ?? 800,
}));
if (typeof details.tabId === 'number') {
try {
await chrome.scripting.executeScript({
target: { tabId: details.tabId, allFrames: true },
files: ['inject-scripts/dom-observer.js'],
world: 'ISOLATED',
} as any);
await chrome.tabs.sendMessage(details.tabId, {
action: 'set_dom_triggers',
triggers: domTriggers,
} as any);
} catch {}
}
} catch {}
const triggers = await listTriggers();
const list = triggers.filter((x) => x.type === 'url' && x.enabled !== false) as any[];
for (const t of list) {
@@ -380,6 +410,33 @@ async function initTriggerEngine() {
await refreshTriggers();
}
// Ensure core content scripts are present for a tab after navigation
async function ensureCoreInjected(tabId?: number) {
try {
if (typeof tabId !== 'number') return;
// Ping accessibility helper
const ok = await pingTab(tabId, CONTENT_MESSAGE_TYPES.ACCESSIBILITY_TREE_HELPER_PING);
if (!ok) {
await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
files: ['inject-scripts/inject-bridge.js', 'inject-scripts/accessibility-tree-helper.js'],
world: 'ISOLATED',
} as any);
}
} catch {}
}
async function pingTab(tabId: number, action: string): Promise<boolean> {
try {
const resp: any = await chrome.tabs.sendMessage(tabId, { action } as any);
if (!resp) return false;
// Helpers generally respond { status: 'pong' } or { ok: true }
return resp.status === 'pong' || resp.ok === true;
} catch {
return false;
}
}
// Alarm listener executes scheduled flows
chrome.alarms.onAlarm.addListener(async (alarm) => {
try {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
import type { StepAssert } from '../types';
import { expandTemplatesDeep } from '../rr-utils';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const assertNode: NodeRuntime<StepAssert> = {
validate: (step) => {
const s = step as any;
const ok = !!s.assert;
if (ok && s.assert && 'attribute' in s.assert) {
const a = s.assert.attribute || {};
if (!a.selector || !a.name)
return { ok: false, errors: ['assert.attribute: 需提供 selector 与 name'] };
}
return ok ? { ok } : { ok, errors: ['缺少断言条件'] };
},
run: async (ctx: ExecCtx, step: StepAssert) => {
const s = expandTemplatesDeep(step as StepAssert, {}) as any;
const failStrategy = (s as any).failStrategy || 'stop';
const fail = (msg: string) => {
if (failStrategy === 'warn') {
ctx.logger({ stepId: (step as any).id, status: 'warning', message: msg });
return { alreadyLogged: true } as any;
}
throw new Error(msg);
};
if ('textPresent' in s.assert) {
const text = (s.assert as any).textPresent;
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.COMPUTER,
args: { action: 'wait', text, appear: true, timeout: (step as any).timeoutMs || 5000 },
});
if ((res as any).isError) return fail('assert text failed');
} else if ('exists' in s.assert || 'visible' in s.assert) {
const selector = (s.assert as any).exists || (s.assert as any).visible;
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const firstTab = tabs && tabs[0];
const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;
if (!tabId) return fail('Active tab not found');
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
const ensured: any = (await chrome.tabs.sendMessage(tabId, {
action: 'ensureRefForSelector',
selector,
} as any)) as any;
if (!ensured || !ensured.success) return fail('assert selector not found');
if ('visible' in s.assert) {
const rect = ensured && ensured.center ? ensured.center : null;
if (!rect) return fail('assert visible failed');
}
} else if ('attribute' in s.assert) {
const { selector, name, equals, matches } = (s.assert as any).attribute || {};
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const firstTab = tabs && tabs[0];
const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;
if (!tabId) return fail('Active tab not found');
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
const resp: any = (await chrome.tabs.sendMessage(
tabId,
{ action: 'getAttributeForSelector', selector, name } as any,
{ frameId: ctx.frameId } as any,
)) as any;
if (!resp || !resp.success) return fail('assert attribute: element not found');
const actual: string | null = resp.value ?? null;
if (equals !== undefined && equals !== null) {
const expected = String(equals);
if (String(actual) !== String(expected))
return fail(
`assert attribute equals failed: ${name} actual=${String(actual)} expected=${String(expected)}`,
);
} else if (matches !== undefined && matches !== null) {
try {
const re = new RegExp(String(matches));
if (!re.test(String(actual)))
return fail(
`assert attribute matches failed: ${name} actual=${String(actual)} regex=${String(matches)}`,
);
} catch {
return fail(`invalid regex for attribute matches: ${String(matches)}`);
}
} else {
if (actual == null) return fail(`assert attribute failed: ${name} missing`);
}
}
return {} as ExecResult;
},
};
@@ -0,0 +1,108 @@
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
import type { Step } from '../types';
import { locateElement } from '../selector-engine';
import { expandTemplatesDeep } from '../rr-utils';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const clickNode: NodeRuntime<any> = {
validate: (step) => {
const ok = !!(step as any).target?.candidates?.length;
return ok ? { ok } : { ok, errors: ['缺少目标选择器候选'] };
},
run: async (ctx: ExecCtx, step: Step) => {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const firstTab = tabs && tabs[0];
const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;
if (!tabId) throw new Error('Active tab not found');
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
const s: any = expandTemplatesDeep(step as any, ctx.vars);
const located = await locateElement(tabId, s.target, ctx.frameId);
const frameId = (located as any)?.frameId ?? ctx.frameId;
const first = s.target?.candidates?.[0]?.type;
const resolvedBy = (located as any)?.resolvedBy || ((located as any)?.ref ? 'ref' : '');
const fallbackUsed = resolvedBy && first && resolvedBy !== 'ref' && resolvedBy !== first;
if ((located as any)?.ref) {
const resolved: any = (await chrome.tabs.sendMessage(
tabId,
{ action: 'resolveRef', ref: (located as any).ref } as any,
{ frameId } as any,
)) as any;
const rect = resolved?.rect;
if (!rect || rect.width <= 0 || rect.height <= 0) throw new Error('element not visible');
}
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.CLICK,
args: {
ref: (located as any)?.ref || (step as any).target?.ref,
selector: !(located as any)?.ref
? s.target?.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value
: undefined,
waitForNavigation: false,
timeout: Math.max(1000, Math.min(s.timeoutMs || 10000, 30000)),
frameId,
},
});
if ((res as any).isError) throw new Error('click failed');
if (fallbackUsed)
ctx.logger({
stepId: step.id,
status: 'success',
message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`,
fallbackUsed: true,
fallbackFrom: String(first),
fallbackTo: String(resolvedBy),
} as any);
return {} as ExecResult;
},
};
export const dblclickNode: NodeRuntime<any> = {
validate: clickNode.validate,
run: async (ctx: ExecCtx, step: Step) => {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const firstTab = tabs && tabs[0];
const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;
if (!tabId) throw new Error('Active tab not found');
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
const s: any = expandTemplatesDeep(step as any, ctx.vars);
const located = await locateElement(tabId, s.target, ctx.frameId);
const frameId = (located as any)?.frameId ?? ctx.frameId;
const first = s.target?.candidates?.[0]?.type;
const resolvedBy = (located as any)?.resolvedBy || ((located as any)?.ref ? 'ref' : '');
const fallbackUsed = resolvedBy && first && resolvedBy !== 'ref' && resolvedBy !== first;
if ((located as any)?.ref) {
const resolved: any = (await chrome.tabs.sendMessage(
tabId,
{ action: 'resolveRef', ref: (located as any).ref } as any,
{ frameId } as any,
)) as any;
const rect = resolved?.rect;
if (!rect || rect.width <= 0 || rect.height <= 0) throw new Error('element not visible');
}
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.CLICK,
args: {
ref: (located as any)?.ref || (step as any).target?.ref,
selector: !(located as any)?.ref
? s.target?.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value
: undefined,
waitForNavigation: false,
timeout: Math.max(1000, Math.min(s.timeoutMs || 10000, 30000)),
frameId,
double: true,
},
});
if ((res as any).isError) throw new Error('dblclick failed');
if (fallbackUsed)
ctx.logger({
stepId: step.id,
status: 'success',
message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`,
fallbackUsed: true,
fallbackFrom: String(first),
fallbackTo: String(resolvedBy),
} as any);
return {} as ExecResult;
},
};
@@ -0,0 +1,55 @@
import type { Step } from '../types';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const ifNode: NodeRuntime<any> = {
validate: (step) => {
const s = step as any;
const hasBranches = Array.isArray(s.branches) && s.branches.length > 0;
const ok = hasBranches || !!s.condition;
return ok ? { ok } : { ok, errors: ['缺少条件或分支'] };
},
run: async (ctx: ExecCtx, step: Step) => {
const s: any = step;
if (Array.isArray(s.branches) && s.branches.length > 0) {
const evalExpr = (expr: string): boolean => {
const code = String(expr || '').trim();
if (!code) return false;
try {
const fn = new Function(
'vars',
'workflow',
`try { return !!(${code}); } catch (e) { return false; }`,
);
return !!fn(ctx.vars, ctx.vars);
} catch {
return false;
}
};
for (const br of s.branches) {
if (br?.expr && evalExpr(String(br.expr)))
return { nextLabel: String(br.label || `case:${br.id || 'match'}`) } as ExecResult;
}
if ('else' in s) return { nextLabel: String(s.else || 'default') } as ExecResult;
return { nextLabel: 'default' } as ExecResult;
}
// legacy condition: { var/equals | expression }
try {
let result = false;
const cond = s.condition;
if (cond && typeof cond.expression === 'string' && cond.expression.trim()) {
const fn = new Function(
'vars',
`try { return !!(${cond.expression}); } catch (e) { return false; }`,
);
result = !!fn(ctx.vars);
} else if (cond && typeof cond.var === 'string') {
const v = ctx.vars[cond.var];
if ('equals' in cond) result = String(v) === String(cond.equals);
else result = !!v;
}
return { nextLabel: result ? 'true' : 'false' } as ExecResult;
} catch {
return { nextLabel: 'false' } as ExecResult;
}
},
};
@@ -0,0 +1,252 @@
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
import { expandTemplatesDeep } from '../rr-utils';
import type { Step } from '../types';
import { locateElement } from '../selector-engine';
export const handleDownloadNode: NodeRuntime<any> = {
run: async (ctx, step) => {
const s: any = expandTemplatesDeep(step as any, ctx.vars);
const args: any = {
filenameContains: s.filenameContains || undefined,
timeoutMs: Math.max(1000, Math.min(Number(s.timeoutMs ?? 60000), 300000)),
waitForComplete: s.waitForComplete !== false,
};
const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.HANDLE_DOWNLOAD, args });
const text = (res as any)?.content?.find((c: any) => c.type === 'text')?.text;
try {
const payload = text ? JSON.parse(text) : null;
if (s.saveAs && payload && payload.download) ctx.vars[s.saveAs] = payload.download;
} catch {}
return {} as ExecResult;
},
};
export const screenshotNode: NodeRuntime<any> = {
run: async (ctx, step) => {
const s: any = expandTemplatesDeep(step as any, ctx.vars);
const args: any = { name: 'workflow', storeBase64: true };
if (s.fullPage) args.fullPage = true;
if (s.selector && typeof s.selector === 'string' && s.selector.trim())
args.selector = s.selector;
const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.SCREENSHOT, args });
const text = (res as any)?.content?.find((c: any) => c.type === 'text')?.text;
try {
const payload = text ? JSON.parse(text) : null;
if (s.saveAs && payload && payload.base64Data) ctx.vars[s.saveAs] = payload.base64Data;
} catch {}
return {} as ExecResult;
},
};
export const triggerEventNode: NodeRuntime<any> = {
validate: (step) => {
const s: any = step;
const ok = !!s?.target?.candidates?.length && typeof s?.event === 'string' && s.event;
return ok ? { ok } : { ok, errors: ['缺少目标选择器或事件类型'] };
},
run: async (ctx, step) => {
const s: any = expandTemplatesDeep(step as any, ctx.vars);
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
const located = await locateElement(tabId, s.target, ctx.frameId);
const cssSelector = !(located as any)?.ref
? s.target.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value
: undefined;
let sel = cssSelector as string | undefined;
if (!sel && (located as any)?.ref) {
try {
const resolved: any = (await chrome.tabs.sendMessage(
tabId,
{ action: 'resolveRef', ref: (located as any).ref } as any,
{ frameId: ctx.frameId } as any,
)) as any;
sel = resolved?.selector;
} catch {}
}
if (!sel) throw new Error('triggerEvent: selector not resolved');
const world: any = 'MAIN';
const ev = String(s.event || '').trim();
const bubbles = s.bubbles !== false;
const cancelable = s.cancelable === true;
await chrome.scripting.executeScript({
target: {
tabId,
frameIds: typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined,
} as any,
world,
func: (selector: string, type: string, bubbles: boolean, cancelable: boolean) => {
try {
const el = document.querySelector(selector);
if (!el) return false;
const e = new Event(type, { bubbles, cancelable });
(el as any).dispatchEvent(e);
return true;
} catch (e) {
return false;
}
},
args: [sel, ev, !!bubbles, !!cancelable],
} as any);
return {} as ExecResult;
},
};
export const setAttributeNode: NodeRuntime<any> = {
validate: (step) => {
const s: any = step;
const ok = !!s?.target?.candidates?.length && typeof s?.name === 'string' && s.name;
return ok ? { ok } : { ok, errors: ['需提供目标选择器与属性名'] };
},
run: async (ctx, step) => {
const s: any = expandTemplatesDeep(step as any, ctx.vars);
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
const located = await locateElement(tabId, s.target, ctx.frameId);
const frameId = (located as any)?.frameId ?? ctx.frameId;
const cssSelector = !(located as any)?.ref
? s.target.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value
: undefined;
let sel = cssSelector as string | undefined;
if (!sel && (located as any)?.ref) {
try {
const resolved: any = (await chrome.tabs.sendMessage(
tabId,
{ action: 'resolveRef', ref: (located as any).ref } as any,
{ frameId } as any,
)) as any;
sel = resolved?.selector;
} catch {}
}
if (!sel) throw new Error('setAttribute: selector not resolved');
const world: any = 'MAIN';
const name = String(s.name || '');
const value = s.value;
const remove = s.remove === true;
await chrome.scripting.executeScript({
target: { tabId, frameIds: typeof frameId === 'number' ? [frameId] : undefined } as any,
world,
func: (selector: string, name: string, value: any, remove: boolean) => {
try {
const el = document.querySelector(selector) as any;
if (!el) return false;
if (remove) el.removeAttribute(name);
else el.setAttribute(name, String(value ?? ''));
return true;
} catch {
return false;
}
},
args: [sel, name, value, remove],
} as any);
return {} as ExecResult;
},
};
export const switchFrameNode: NodeRuntime<any> = {
run: async (ctx, step) => {
const s: any = step;
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
const frames = await chrome.webNavigation.getAllFrames({ tabId });
if (!Array.isArray(frames) || frames.length === 0) {
ctx.frameId = undefined;
return {} as ExecResult;
}
let target: any | undefined;
const idx = Number(s?.frame?.index ?? NaN);
if (Number.isFinite(idx)) {
const list = frames.filter((f) => f.frameId !== 0);
target = list[Math.max(0, Math.min(list.length - 1, idx))];
}
const urlContains = String(s?.frame?.urlContains || '').trim();
if (!target && urlContains)
target = frames.find((f) => typeof f.url === 'string' && f.url.includes(urlContains));
if (!target) ctx.frameId = undefined;
else ctx.frameId = target.frameId;
try {
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
} catch {}
ctx.logger({
stepId: (step as any).id,
status: 'success',
message: `frameId=${String(ctx.frameId ?? 'top')}`,
} as any);
return {} as ExecResult;
},
};
export const loopElementsNode: NodeRuntime<any> = {
validate: (step) => {
const s: any = step;
const ok =
typeof s?.selector === 'string' &&
s.selector &&
typeof s?.subflowId === 'string' &&
s.subflowId;
return ok ? { ok } : { ok, errors: ['需提供 selector 与 subflowId'] };
},
run: async (ctx, step) => {
const s: any = expandTemplatesDeep(step as any, ctx.vars);
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
const world: any = 'MAIN';
const selector = String(s.selector || '');
const res = await chrome.scripting.executeScript({
target: {
tabId,
frameIds: typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined,
} as any,
world,
func: (sel: string) => {
try {
const list = Array.from(document.querySelectorAll(sel));
const toCss = (node: Element) => {
try {
if ((node as HTMLElement).id) {
const idSel = `#${CSS.escape((node as HTMLElement).id)}`;
if (document.querySelectorAll(idSel).length === 1) return idSel;
}
} catch {}
let path = '';
let current: Element | null = node;
while (current && current.tagName !== 'BODY') {
let part = current.tagName.toLowerCase();
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter(
(c) => (c as any).tagName === current!.tagName,
);
if (siblings.length > 1) {
const idx = siblings.indexOf(current) + 1;
part += `:nth-of-type(${idx})`;
}
}
path = path ? `${part} > ${path}` : part;
current = parent;
}
return path ? `body > ${path}` : 'body';
};
return list.map(toCss);
} catch (e) {
return [];
}
},
args: [selector],
} as any);
const arr: string[] = (res && Array.isArray(res[0]?.result) ? res[0].result : []) as any;
const listVar = String(s.saveAs || 'elements');
const itemVar = String(s.itemVar || 'item');
ctx.vars[listVar] = arr;
return {
control: { kind: 'foreach', listVar, itemVar, subflowId: String(s.subflowId) },
} as any;
},
};
@@ -0,0 +1,42 @@
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
import type { StepDrag } from '../types';
import { locateElement } from '../selector-engine';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const dragNode: NodeRuntime<StepDrag> = {
run: async (_ctx, step: StepDrag) => {
const s = step as StepDrag;
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
let startRef: string | undefined;
let endRef: string | undefined;
try {
if (typeof tabId === 'number') {
const locatedStart = await locateElement(tabId, (s as any).start);
const locatedEnd = await locateElement(tabId, (s as any).end);
startRef = (locatedStart as any)?.ref || (s as any).start.ref;
endRef = (locatedEnd as any)?.ref || (s as any).end.ref;
}
} catch {}
let startCoordinates: { x: number; y: number } | undefined;
let endCoordinates: { x: number; y: number } | undefined;
if ((!startRef || !endRef) && Array.isArray((s as any).path) && (s as any).path.length >= 2) {
startCoordinates = { x: Number((s as any).path[0].x), y: Number((s as any).path[0].y) };
const last = (s as any).path[(s as any).path.length - 1];
endCoordinates = { x: Number(last.x), y: Number(last.y) };
}
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.COMPUTER,
args: {
action: 'left_click_drag',
startRef,
ref: endRef,
startCoordinates,
coordinates: endCoordinates,
},
});
if ((res as any).isError) throw new Error('drag failed');
return {} as ExecResult;
},
};
@@ -0,0 +1,88 @@
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const executeFlowNode: NodeRuntime<any> = {
validate: (step) => {
const s: any = step;
const ok = typeof s.flowId === 'string' && !!s.flowId;
return ok ? { ok } : { ok, errors: ['需提供 flowId'] };
},
run: async (ctx: ExecCtx, step) => {
const s: any = step;
const { getFlow } = await import('../flow-store');
const flow = await getFlow(String(s.flowId));
if (!flow) throw new Error('referenced flow not found');
const inline = s.inline !== false; // default inline
if (!inline) {
const { runFlow } = await import('../flow-runner');
await runFlow(flow, { args: s.args || {}, returnLogs: false });
return {} as ExecResult;
}
const { defaultEdgesOnly, topoOrder, mapDagNodeToStep, waitForNetworkIdle, waitForNavigation } =
await import('../rr-utils');
const vars = ctx.vars;
if (s.args && typeof s.args === 'object') Object.assign(vars, s.args);
const hasDag = Array.isArray((flow as any).nodes) && (flow as any).nodes.length > 0;
const nodes = hasDag ? (((flow as any).nodes || []) as any[]) : [];
const edges = hasDag ? (((flow as any).edges || []) as any[]) : [];
const defaultEdges = hasDag ? defaultEdgesOnly(edges as any) : [];
const order = hasDag ? topoOrder(nodes as any, defaultEdges as any) : [];
const stepsToRun: any[] = hasDag
? order.map((n) => mapDagNodeToStep(n as any))
: (((flow as any).steps || []) as any[]);
for (const st of stepsToRun) {
const t0 = Date.now();
const maxRetries = Math.max(0, (st as any).retry?.count ?? 0);
const baseInterval = Math.max(0, (st as any).retry?.intervalMs ?? 0);
let attempt = 0;
const doDelay = async (i: number) => {
const delay =
baseInterval > 0
? (st as any).retry?.backoff === 'exp'
? baseInterval * Math.pow(2, i)
: baseInterval
: 0;
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
};
while (true) {
try {
const beforeInfo = await (async () => {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
return { url: tab?.url || '', status: (tab as any)?.status || '' };
})();
const { executeStep } = await import('../nodes');
const result = await executeStep(ctx as any, st as any);
if ((st.type === 'click' || st.type === 'dblclick') && (st as any).after) {
const after = (st as any).after as any;
if (after.waitForNavigation)
await waitForNavigation((st as any).timeoutMs, beforeInfo.url);
else if (after.waitForNetworkIdle)
await waitForNetworkIdle(Math.min((st as any).timeoutMs || 5000, 120000), 1200);
}
if (!result?.alreadyLogged)
ctx.logger({ stepId: st.id, status: 'success', tookMs: Date.now() - t0 } as any);
break;
} catch (e: any) {
if (attempt < maxRetries) {
ctx.logger({
stepId: st.id,
status: 'retrying',
message: e?.message || String(e),
} as any);
await doDelay(attempt);
attempt += 1;
continue;
}
ctx.logger({
stepId: st.id,
status: 'failed',
message: e?.message || String(e),
tookMs: Date.now() - t0,
} as any);
throw e;
}
}
}
return {} as ExecResult;
},
};
@@ -0,0 +1,47 @@
import type { StepExtract } from '../types';
import { expandTemplatesDeep } from '../rr-utils';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const extractNode: NodeRuntime<StepExtract> = {
run: async (ctx: ExecCtx, step: StepExtract) => {
const s: any = expandTemplatesDeep(step as any, ctx.vars);
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
let value: any = null;
if (s.js && String(s.js).trim()) {
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (code: string) => {
try {
return (0, eval)(code);
} catch (e) {
return null;
}
},
args: [String(s.js)],
} as any);
value = result;
} else if (s.selector) {
const attr = String(s.attr || 'text');
const sel = String(s.selector);
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (selector: string, attr: string) => {
try {
const el = document.querySelector(selector) as any;
if (!el) return null;
if (attr === 'text' || attr === 'textContent') return (el.textContent || '').trim();
return el.getAttribute ? el.getAttribute(attr) : null;
} catch {
return null;
}
},
args: [sel, attr],
} as any);
value = result;
}
if (s.saveAs) ctx.vars[s.saveAs] = value;
return {} as ExecResult;
},
};
@@ -0,0 +1,116 @@
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
import type { StepFill } from '../types';
import { locateElement } from '../selector-engine';
import { expandTemplatesDeep } from '../rr-utils';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const fillNode: NodeRuntime<StepFill> = {
validate: (step) => {
const ok = !!(step as any).target?.candidates?.length && 'value' in (step as any);
return ok ? { ok } : { ok, errors: ['缺少目标选择器候选或输入值'] };
},
run: async (ctx: ExecCtx, step: StepFill) => {
const s: any = step;
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const firstTab = tabs && tabs[0];
const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;
if (!tabId) throw new Error('Active tab not found');
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
const located = await locateElement(tabId, s.target, ctx.frameId);
const frameId = (located as any)?.frameId ?? ctx.frameId;
const first = s.target?.candidates?.[0]?.type;
const resolvedBy = (located as any)?.resolvedBy || ((located as any)?.ref ? 'ref' : '');
const fallbackUsed = resolvedBy && first && resolvedBy !== 'ref' && resolvedBy !== first;
const interpolate = (v: any) =>
typeof v === 'string'
? v.replace(/\{([^}]+)\}/g, (_m, k) => (ctx.vars[k] ?? '').toString())
: v;
const value = interpolate(s.value);
if ((located as any)?.ref) {
const resolved: any = (await chrome.tabs.sendMessage(
tabId,
{ action: 'resolveRef', ref: (located as any).ref } as any,
{ frameId } as any,
)) as any;
const rect = resolved?.rect;
if (!rect || rect.width <= 0 || rect.height <= 0) throw new Error('element not visible');
}
const cssSelector = !(located as any)?.ref
? s.target.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value
: undefined;
if (cssSelector) {
try {
const attr: any = (await chrome.tabs.sendMessage(
tabId,
{ action: 'getAttributeForSelector', selector: cssSelector, name: 'type' } as any,
{ frameId } as any,
)) as any;
const typeName = (attr && attr.value ? String(attr.value) : '').toLowerCase();
if (typeName === 'file') {
const uploadRes = await handleCallTool({
name: TOOL_NAMES.BROWSER.FILE_UPLOAD,
args: { selector: cssSelector, filePath: String(value ?? '') },
});
if ((uploadRes as any).isError) throw new Error('file upload failed');
if (fallbackUsed)
ctx.logger({
stepId: (step as any).id,
status: 'success',
message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`,
fallbackUsed: true,
fallbackFrom: String(first),
fallbackTo: String(resolvedBy),
} as any);
return {} as ExecResult;
}
} catch {}
}
try {
if (cssSelector)
await handleCallTool({
name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
args: {
type: 'MAIN',
jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el){el.scrollIntoView({behavior:'instant',block:'center',inline:'nearest'});} }catch(e){}`,
},
});
} catch {}
try {
if ((located as any)?.ref)
await chrome.tabs.sendMessage(
tabId,
{ action: 'focusByRef', ref: (located as any).ref } as any,
{ frameId } as any,
);
else if (cssSelector)
await handleCallTool({
name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
args: {
type: 'MAIN',
jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el&&el.focus){el.focus();}}catch(e){}`,
},
});
} catch {}
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.FILL,
args: {
ref: (located as any)?.ref || (s as any).target?.ref,
selector: cssSelector,
value,
frameId,
},
});
if ((res as any).isError) throw new Error('fill failed');
if (fallbackUsed)
ctx.logger({
stepId: (step as any).id,
status: 'success',
message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`,
fallbackUsed: true,
fallbackFrom: String(first),
fallbackTo: String(resolvedBy),
} as any);
return {} as ExecResult;
},
};
@@ -0,0 +1,28 @@
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
import type { StepHttp } from '../types';
import { applyAssign, expandTemplatesDeep } from '../rr-utils';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const httpNode: NodeRuntime<StepHttp> = {
run: async (ctx: ExecCtx, step: StepHttp) => {
const s: any = expandTemplatesDeep(step as any, ctx.vars);
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.NETWORK_REQUEST,
args: {
url: s.url,
method: s.method || 'GET',
headers: s.headers || {},
body: s.body,
formData: s.formData,
},
});
const text = (res as any)?.content?.find((c: any) => c.type === 'text')?.text;
try {
const payload = text ? JSON.parse(text) : null;
if (s.saveAs && payload !== undefined) ctx.vars[s.saveAs] = payload;
if (s.assign && payload !== undefined) applyAssign(ctx.vars, payload, s.assign);
} catch {}
return {} as ExecResult;
},
};
@@ -0,0 +1,64 @@
import type { Step } from '../types';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
import { clickNode, dblclickNode } from './click';
import { fillNode } from './fill';
import { httpNode } from './http';
import { extractNode } from './extract';
import { scriptNode } from './script';
import { openTabNode, switchTabNode, closeTabNode } from './tabs';
import { scrollNode } from './scroll';
import { dragNode } from './drag';
import { keyNode } from './key';
import { waitNode } from './wait';
import { assertNode } from './assert';
import { navigateNode } from './navigate';
import { ifNode } from './conditional';
import { foreachNode, whileNode } from './loops';
import { executeFlowNode } from './execute-flow';
import {
handleDownloadNode,
screenshotNode,
triggerEventNode,
setAttributeNode,
switchFrameNode,
loopElementsNode,
} from './download-screenshot-attr-event-frame-loop';
const registry = new Map<string, NodeRuntime<any>>([
['click', clickNode],
['dblclick', dblclickNode],
['fill', fillNode],
['http', httpNode],
['extract', extractNode],
['script', scriptNode],
['openTab', openTabNode],
['switchTab', switchTabNode],
['closeTab', closeTabNode],
['scroll', scrollNode],
['drag', dragNode],
['key', keyNode],
['wait', waitNode],
['assert', assertNode],
['navigate', navigateNode],
['if', ifNode],
['foreach', foreachNode],
['while', whileNode],
['executeFlow', executeFlowNode],
['handleDownload', handleDownloadNode],
['screenshot', screenshotNode],
['triggerEvent', triggerEventNode],
['setAttribute', setAttributeNode],
['switchFrame', switchFrameNode],
['loopElements', loopElementsNode],
]);
export async function executeStep(ctx: ExecCtx, step: Step): Promise<ExecResult> {
const rt = registry.get((step as any).type);
if (!rt) throw new Error(`unsupported step type: ${String((step as any).type)}`);
const v = rt.validate ? rt.validate(step as any) : { ok: true };
if (!(v as any).ok) throw new Error(((v as any).errors || []).join(', ') || 'validation failed');
const out = await rt.run(ctx as any, step as any);
return (out || {}) as ExecResult;
}
export type { ExecCtx, ExecResult, NodeRuntime } from './types';
@@ -0,0 +1,17 @@
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
import type { StepKey } from '../types';
import { expandTemplatesDeep } from '../rr-utils';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const keyNode: NodeRuntime<StepKey> = {
run: async (_ctx, step: StepKey) => {
const s = expandTemplatesDeep(step as StepKey, {});
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.KEYBOARD,
args: { keys: (s as StepKey).keys },
});
if ((res as any).isError) throw new Error('key failed');
return {} as ExecResult;
},
};
@@ -0,0 +1,42 @@
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const foreachNode: NodeRuntime<any> = {
validate: (step) => {
const s = step as any;
const ok =
typeof s.listVar === 'string' && s.listVar && typeof s.subflowId === 'string' && s.subflowId;
return ok ? { ok } : { ok, errors: ['foreach: 需提供 listVar 与 subflowId'] };
},
run: async (_ctx: ExecCtx, step) => {
const s: any = step;
const itemVar = typeof s.itemVar === 'string' && s.itemVar ? s.itemVar : 'item';
return {
control: {
kind: 'foreach',
listVar: String(s.listVar),
itemVar,
subflowId: String(s.subflowId),
},
} as ExecResult;
},
};
export const whileNode: NodeRuntime<any> = {
validate: (step) => {
const s = step as any;
const ok = !!s.condition && typeof s.subflowId === 'string' && s.subflowId;
return ok ? { ok } : { ok, errors: ['while: 需提供 condition 与 subflowId'] };
},
run: async (_ctx: ExecCtx, step) => {
const s: any = step;
const max = Math.max(1, Math.min(10000, Number(s.maxIterations ?? 100)));
return {
control: {
kind: 'while',
condition: s.condition,
subflowId: String(s.subflowId),
maxIterations: max,
},
} as ExecResult;
},
};
@@ -0,0 +1,17 @@
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
import type { Step } from '../types';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const navigateNode: NodeRuntime<any> = {
validate: (step) => {
const ok = !!(step as any).url;
return ok ? { ok } : { ok, errors: ['缺少 URL'] };
},
run: async (_ctx: ExecCtx, step: Step) => {
const url = (step as any).url;
const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { url } });
if ((res as any).isError) throw new Error('navigate failed');
return {} as ExecResult;
},
};
@@ -0,0 +1,31 @@
import type { StepScript } from '../types';
import { expandTemplatesDeep, applyAssign } from '../rr-utils';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const scriptNode: NodeRuntime<StepScript> = {
run: async (ctx: ExecCtx, step: StepScript) => {
const s: any = expandTemplatesDeep(step as any, ctx.vars);
if (s.when === 'after') return { deferAfterScript: s } as ExecResult;
const world = s.world || 'ISOLATED';
const code = String(s.code || '');
if (!code.trim()) return {} as ExecResult;
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (userCode: string) => {
try {
return (0, eval)(userCode);
} catch {
return null;
}
},
args: [code],
world: world as any,
} as any);
if (s.saveAs) ctx.vars[s.saveAs] = result;
if (s.assign && typeof s.assign === 'object') applyAssign(ctx.vars, result, s.assign);
return {} as ExecResult;
},
};
@@ -0,0 +1,45 @@
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
import type { StepScroll } from '../types';
import { expandTemplatesDeep } from '../rr-utils';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const scrollNode: NodeRuntime<StepScroll> = {
run: async (_ctx, step: StepScroll) => {
const s = expandTemplatesDeep(step as StepScroll, {});
const top = s.offset?.y ?? undefined;
const left = s.offset?.x ?? undefined;
const selectorFromTarget = (s as any).target?.candidates?.find(
(c: any) => c.type === 'css' || c.type === 'attr',
)?.value;
let code = '';
if (s.mode === 'offset' && !(s as any).target) {
const t = top != null ? Number(top) : 'undefined';
const l = left != null ? Number(left) : 'undefined';
code = `try { window.scrollTo({ top: ${t}, left: ${l}, behavior: 'instant' }); } catch (e) {}`;
} else if (s.mode === 'element' && selectorFromTarget) {
code = `(() => { try { const el = document.querySelector(${JSON.stringify(selectorFromTarget)}); if (el) el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' }); } catch (e) {} })();`;
} else if (s.mode === 'container' && selectorFromTarget) {
const t = top != null ? Number(top) : 'undefined';
const l = left != null ? Number(left) : 'undefined';
code = `(() => { try { const el = document.querySelector(${JSON.stringify(selectorFromTarget)}); if (el && typeof el.scrollTo === 'function') el.scrollTo({ top: ${t}, left: ${l}, behavior: 'instant' }); } catch (e) {} })();`;
} else {
const direction = top != null && Number(top) < 0 ? 'up' : 'down';
const amount = 3;
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.COMPUTER,
args: { action: 'scroll', scrollDirection: direction, scrollAmount: amount },
});
if ((res as any).isError) throw new Error('scroll failed');
return {} as ExecResult;
}
if (code) {
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
args: { type: 'MAIN', jsScript: code },
});
if ((res as any).isError) throw new Error('scroll failed');
}
return {} as ExecResult;
},
};
@@ -0,0 +1,49 @@
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
import type { StepOpenTab, StepSwitchTab, StepCloseTab } from '../types';
import { expandTemplatesDeep } from '../rr-utils';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const openTabNode: NodeRuntime<StepOpenTab> = {
run: async (_ctx, step) => {
const s: any = expandTemplatesDeep(step as any, {});
if (s.newWindow) await chrome.windows.create({ url: s.url || undefined, focused: true });
else await chrome.tabs.create({ url: s.url || undefined, active: true });
return {} as ExecResult;
},
};
export const switchTabNode: NodeRuntime<StepSwitchTab> = {
run: async (_ctx, step) => {
const s: any = expandTemplatesDeep(step as any, {});
let targetTabId: number | undefined = s.tabId;
if (!targetTabId) {
const tabs = await chrome.tabs.query({});
const hit = tabs.find(
(t) =>
(s.urlContains && (t.url || '').includes(String(s.urlContains))) ||
(s.titleContains && (t.title || '').includes(String(s.titleContains))),
);
targetTabId = (hit && hit.id) as number | undefined;
}
if (!targetTabId) throw new Error('switchTab: no matching tab');
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.SWITCH_TAB,
args: { tabId: targetTabId },
});
if ((res as any).isError) throw new Error('switchTab failed');
return {} as ExecResult;
},
};
export const closeTabNode: NodeRuntime<StepCloseTab> = {
run: async (_ctx, step) => {
const s: any = expandTemplatesDeep(step as any, {});
const args: any = {};
if (Array.isArray(s.tabIds) && s.tabIds.length) args.tabIds = s.tabIds;
if (s.url) args.url = s.url;
const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.CLOSE_TABS, args });
if ((res as any).isError) throw new Error('closeTab failed');
return {} as ExecResult;
},
};
@@ -0,0 +1,21 @@
import type { RunLogEntry, Step, StepScript } from '../types';
export interface ExecCtx {
vars: Record<string, any>;
logger: (e: RunLogEntry) => void;
frameId?: number;
}
export interface ExecResult {
alreadyLogged?: boolean;
deferAfterScript?: StepScript | null;
nextLabel?: string;
control?:
| { kind: 'foreach'; listVar: string; itemVar: string; subflowId: string }
| { kind: 'while'; condition: any; subflowId: string; maxIterations: number };
}
export interface NodeRuntime<S extends Step = Step> {
validate?: (step: S) => { ok: boolean; errors?: string[] };
run: (ctx: ExecCtx, step: S) => Promise<ExecResult | void>;
}
@@ -0,0 +1,67 @@
import type { StepWait } from '../types';
import { waitForNetworkIdle, waitForNavigation } from '../rr-utils';
import { expandTemplatesDeep } from '../rr-utils';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const waitNode: NodeRuntime<StepWait> = {
validate: (step) => {
const ok = !!(step as any).condition;
return ok ? { ok } : { ok, errors: ['缺少等待条件'] };
},
run: async (ctx: ExecCtx, step: StepWait) => {
const s = expandTemplatesDeep(step as StepWait, {});
const cond = (s as StepWait).condition as
| { selector: string; visible?: boolean }
| { text: string; appear?: boolean }
| { navigation: true }
| { networkIdle: true }
| { sleep: number };
if ('text' in cond) {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
await chrome.scripting.executeScript({
target: { tabId },
files: ['inject-scripts/wait-helper.js'],
world: 'ISOLATED',
} as any);
const resp: any = (await chrome.tabs.sendMessage(
tabId,
{
action: 'waitForText',
text: cond.text,
appear: (cond as any).appear !== false,
timeout: Math.max(0, Math.min((s as any).timeoutMs || 10000, 120000)),
} as any,
{ frameId: ctx.frameId } as any,
)) as any;
if (!resp || resp.success !== true) throw new Error('wait text failed');
} else if ('networkIdle' in cond) {
const total = Math.min(Math.max(1000, (s as any).timeoutMs || 5000), 120000);
const idle = Math.min(1500, Math.max(500, Math.floor(total / 3)));
await waitForNetworkIdle(total, idle);
} else if ('navigation' in cond) {
await waitForNavigation((s as any).timeoutMs);
} else if ('sleep' in cond) {
const ms = Math.max(0, Number(cond.sleep ?? 0));
await new Promise((r) => setTimeout(r, ms));
} else if ('selector' in cond) {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
await chrome.scripting.executeScript({
target: { tabId },
files: ['inject-scripts/wait-helper.js'],
world: 'ISOLATED',
} as any);
const resp: any = (await chrome.tabs.sendMessage(tabId, {
action: 'waitForSelector',
selector: (cond as any).selector,
visible: (cond as any).visible !== false,
timeout: Math.max(0, Math.min((s as any).timeoutMs || 10000, 120000)),
} as any)) as any;
if (!resp || resp.success !== true) throw new Error('wait selector failed');
}
return {} as ExecResult;
},
};
@@ -1,718 +0,0 @@
// runner.ts — orchestrates record-replay flow execution using registry + utils
// Note: comments in English
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '../tools';
import type { Flow, RunLogEntry, RunRecord, RunResult, Step, StepScript } from './types';
import { appendRun } from './flow-store';
import {
mapDagNodeToStep,
topoOrder,
ensureTab,
expandTemplatesDeep,
waitForNetworkIdle,
applyAssign,
defaultEdgesOnly,
waitForNavigation,
} from './rr-utils';
import { executeStep } from './node-registry';
export interface RunOptions {
tabTarget?: 'current' | 'new';
refresh?: boolean;
captureNetwork?: boolean;
returnLogs?: boolean;
timeoutMs?: number;
startUrl?: string;
args?: Record<string, any>;
startNodeId?: string;
}
export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<RunResult> {
const runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const startAt = Date.now();
const logs: RunLogEntry[] = [];
// Global deadline for run (optional)
const globalTimeout = Math.max(0, Number(options.timeoutMs || 0));
const deadline = globalTimeout > 0 ? startAt + globalTimeout : 0;
const ensureWithinDeadline = () => {
if (deadline > 0 && Date.now() > deadline) {
const err = new Error('Global timeout reached');
// mark a synthetic log entry for visibility
logs.push({ stepId: 'global-timeout', status: 'failed', message: 'Global timeout reached' });
throw err;
}
};
// prepare variables
const vars: Record<string, any> = Object.create(null);
for (const v of flow.variables || []) if (v.default !== undefined) vars[v.key] = v.default;
if (options.args) Object.assign(vars, options.args);
// Derive a default startUrl when not provided: prefer first navigate step
let derivedStartUrl: string | undefined = undefined;
try {
// We haven't computed stepsToRun yet; compute minimal set from flow for derive
const hasDag0 = Array.isArray((flow as any).nodes) && (flow as any).nodes.length > 0;
const nodes0 = hasDag0 ? (((flow as any).nodes || []) as any[]) : [];
const edges0 = hasDag0 ? (((flow as any).edges || []) as any[]) : [];
const defaultEdges0 = hasDag0 ? defaultEdgesOnly(edges0 as any) : [];
const order0 = hasDag0 ? topoOrder(nodes0 as any, defaultEdges0 as any) : [];
const steps0: Step[] = hasDag0
? order0.map((n) => mapDagNodeToStep(n as any))
: ((flow.steps || []) as Step[]);
const nav = steps0.find((s: any) => s && (s as any).type === 'navigate') as any;
if (nav && typeof nav.url === 'string') derivedStartUrl = expandTemplatesDeep(nav.url, {});
} catch {}
const ensured = await ensureTab({
tabTarget: options.tabTarget,
startUrl: options.startUrl || derivedStartUrl,
refresh: options.refresh,
});
// pre-load read_page to init bridges only when on a web page (avoid builder.html)
try {
const u = ensured?.url || '';
if (/^(https?:|file:)/i.test(u)) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
}
} catch {}
// collect required variables via overlay prompt
try {
const needed = (flow.variables || []).filter(
(v) =>
(options.args?.[v.key] == null || options.args?.[v.key] === '') &&
(v.rules?.required || (v.default ?? '') === ''),
);
if (needed.length) {
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT,
args: {
eventName: 'collectVariables',
payload: JSON.stringify({ variables: needed, useOverlay: true }),
},
});
let values: Record<string, any> | null = null;
try {
const t = (res?.content || []).find((c: any) => c.type === 'text')?.text;
const j = t ? JSON.parse(t) : null;
if (j && j.success && j.values) values = j.values;
} catch {}
if (!values) {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId === 'number') {
const res2 = await chrome.tabs.sendMessage(tabId, {
action: 'collectVariables',
variables: needed,
useOverlay: true,
} as any);
if (res2 && res2.success && res2.values) values = res2.values;
}
}
if (values) Object.assign(vars, values);
}
} catch {}
// init overlay for on-screen log
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (tabs[0]?.id)
await chrome.tabs.sendMessage(tabs[0].id, { action: 'rr_overlay', cmd: 'init' } as any);
} catch {}
// binding enforcement
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const currentUrl = tabs?.[0]?.url || '';
const bindings = flow.meta?.bindings || [];
if (!options.startUrl && bindings.length > 0) {
const ok = bindings.some((b) => {
try {
if (b.type === 'domain') return new URL(currentUrl).hostname.includes(b.value);
if (b.type === 'path') return new URL(currentUrl).pathname.startsWith(b.value);
if (b.type === 'url') return currentUrl.startsWith(b.value);
} catch {}
return false;
});
if (!ok) {
return {
runId,
success: false,
summary: { total: 0, success: 0, failed: 0, tookMs: 0 },
url: currentUrl,
outputs: null,
logs: [
{
stepId: 'binding-check',
status: 'failed',
message:
'Flow binding mismatch. Provide startUrl or open a page matching flow.meta.bindings.',
},
],
screenshots: { onFailure: null },
} as RunResult;
}
}
} catch {}
// long-running network capture (debugger) if requested
let networkCaptureStarted = false;
const stopAndSummarizeNetwork = async () => {
try {
const stopRes = await handleCallTool({
name: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP,
args: {},
});
const text = (stopRes?.content || []).find((c: any) => c.type === 'text')?.text;
if (!text) return;
const data = JSON.parse(text);
const requests: any[] = Array.isArray(data?.requests) ? data.requests : [];
const snippets = requests
.filter((r) => ['XHR', 'Fetch'].includes(String(r.type)))
.slice(0, 10)
.map((r) => ({
method: String(r.method || 'GET'),
url: String(r.url || ''),
status: r.statusCode || r.status,
ms: Math.max(0, (r.responseTime || 0) - (r.requestTime || 0)),
}));
logs.push({
stepId: 'network-capture',
status: 'success',
message: `Captured ${Number(data?.requestCount || 0)} requests` as any,
networkSnippets: snippets,
} as any);
} catch {}
};
if (options.captureNetwork) {
try {
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START,
args: { includeStatic: false, maxCaptureTime: 3 * 60_000, inactivityTimeout: 0 },
});
if (!(res as any)?.isError) networkCaptureStarted = true;
} catch {}
}
const hasDag = Array.isArray((flow as any).nodes) && (flow as any).nodes.length > 0;
const nodes = hasDag ? (((flow as any).nodes || []) as any[]) : [];
const edges = hasDag ? (((flow as any).edges || []) as any[]) : [];
const defaultEdges = hasDag ? defaultEdgesOnly(edges as any) : [];
const order = hasDag ? topoOrder(nodes as any, defaultEdges as any) : [];
const stepsToRun: Step[] = hasDag
? order.map((n) => mapDagNodeToStep(n as any))
: ((flow.steps || []) as Step[]);
const startIdx =
!hasDag && options.startNodeId
? stepsToRun.findIndex((s) => s?.id === options.startNodeId)
: -1;
const steps = !hasDag
? startIdx >= 0
? stepsToRun.slice(startIdx)
: stepsToRun.slice()
: stepsToRun;
let failed = 0;
const logger = (e: RunLogEntry) => logs.push(e);
const ctx = { vars, logger };
// deferred after-scripts
const pendingAfterScripts: StepScript[] = [];
// small helpers
const appendOverlay = async (text: string) => {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (tabs[0]?.id)
await chrome.tabs.sendMessage(tabs[0].id, {
action: 'rr_overlay',
cmd: 'append',
text,
} as any);
} catch {}
};
const evalCondition = (cond: any): boolean => {
try {
if (cond && typeof cond.expression === 'string' && cond.expression.trim()) {
const fn = new Function(
'vars',
`try { return !!(${cond.expression}); } catch (e) { return false; }`,
);
return !!fn(vars);
}
if (cond && typeof cond.var === 'string') {
const v = vars[cond.var];
if ('equals' in cond) return String(v) === String(cond.equals);
return !!v;
}
} catch {}
return false;
};
// execute a subflow by id (default edges only)
const runSubflowById = async (subflowId: string) => {
const sub = (flow.subflows || {})[subflowId];
if (!sub || !Array.isArray(sub.nodes) || sub.nodes.length === 0) return;
const sNodes: any[] = sub.nodes;
const sEdges: any[] = defaultEdgesOnly((sub.edges || []) as any) as any[];
const sOrder = topoOrder(sNodes as any, sEdges as any);
const sSteps: Step[] = sOrder.map((n) => mapDagNodeToStep(n as any)) as any;
for (const step of sSteps) {
const t0 = Date.now();
const maxRetries = Math.max(0, (step as any).retry?.count ?? 0);
const baseInterval = Math.max(0, (step as any).retry?.intervalMs ?? 0);
let attempt = 0;
const doDelay = async (i: number) => {
const delay =
baseInterval > 0
? (step as any).retry?.backoff === 'exp'
? baseInterval * Math.pow(2, i)
: baseInterval
: 0;
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
};
while (true) {
try {
const beforeInfo = await getActiveTabInfo();
const result = await executeStep(ctx, step);
if (step.type === 'click' || step.type === 'dblclick') {
const after = ((step as any).after || {}) as any;
if (after.waitForNavigation)
await waitForNavigationDone(beforeInfo.url, (step as any).timeoutMs);
else if (after.waitForNetworkIdle)
await waitForNetworkIdle(Math.min((step as any).timeoutMs || 5000, 120000), 1200);
else await maybeQuickWaitForNav(beforeInfo.url, (step as any).timeoutMs);
}
if (step.type === 'navigate' || step.type === 'openTab') {
await waitForNavigationDone(beforeInfo.url, (step as any).timeoutMs);
await ensureReadPageIfWeb();
} else if (step.type === 'switchTab') {
await ensureReadPageIfWeb();
}
if (!result?.alreadyLogged)
logs.push({ stepId: step.id, status: 'success', tookMs: Date.now() - t0 });
await appendOverlay(`✔ ${step.type} (${step.id})`);
break;
} catch (e: any) {
if (attempt < maxRetries) {
logs.push({ stepId: step.id, status: 'retrying', message: e?.message || String(e) });
await doDelay(attempt);
attempt += 1;
continue;
}
logs.push({
stepId: step.id,
status: 'failed',
message: e?.message || String(e),
tookMs: Date.now() - t0,
});
await appendOverlay(`✘ ${step.type} (${step.id}) -> ${e?.message || String(e)}`);
if ((step as any).screenshotOnFail !== false) {
try {
const shot = await handleCallTool({
name: TOOL_NAMES.BROWSER.COMPUTER,
args: { action: 'screenshot' },
});
const img = (shot?.content?.find((c: any) => c.type === 'image') as any)
?.data as string;
if (img) logs[logs.length - 1].screenshotBase64 = img;
} catch {}
}
throw e;
}
}
}
};
const getActiveTabInfo = async () => {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
return { url: tab?.url || '', status: (tab as any)?.status || '' };
};
const waitForNavigationDone = async (prevUrl: string, timeoutMs?: number) => {
await waitForNavigation(timeoutMs, prevUrl);
};
const isWebUrl = (u?: string | null) => !!u && /^(https?:|file:)/i.test(String(u || ''));
const ensureReadPageIfWeb = async () => {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const url = tabs?.[0]?.url || '';
if (isWebUrl(url)) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
}
} catch {}
};
// Opportunistic short-window navigation wait after clicks when not explicitly requested
const maybeQuickWaitForNav = async (prevUrl: string, timeoutMs?: number) => {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') return;
const sniffMs = 350;
const startedAt = Date.now();
let seen = false;
await new Promise<void>((resolve) => {
let timer: any = null;
const cleanup = () => {
try {
chrome.webNavigation.onCommitted.removeListener(onCommitted);
} catch {}
try {
chrome.webNavigation.onCompleted.removeListener(onCompleted);
} catch {}
try {
(chrome.webNavigation as any).onHistoryStateUpdated?.removeListener?.(
onHistoryStateUpdated,
);
} catch {}
try {
chrome.tabs.onUpdated.removeListener(onUpdated);
} catch {}
if (timer) {
try {
clearTimeout(timer);
} catch {}
}
};
const finish = async () => {
cleanup();
if (seen) {
try {
await waitForNavigation(
prevUrl ? Math.min(timeoutMs || 15000, 30000) : undefined,
prevUrl,
);
} catch {}
}
resolve();
};
const mark = () => {
seen = true;
};
const onCommitted = (d: any) => {
if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();
};
const onCompleted = (d: any) => {
if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();
};
const onHistoryStateUpdated = (d: any) => {
if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();
};
const onUpdated = (updatedId: number, change: chrome.tabs.TabChangeInfo) => {
if (updatedId !== tabId) return;
if (change.status === 'loading') mark();
if (typeof change.url === 'string' && (!prevUrl || change.url !== prevUrl)) mark();
};
chrome.webNavigation.onCommitted.addListener(onCommitted);
chrome.webNavigation.onCompleted.addListener(onCompleted);
try {
(chrome.webNavigation as any).onHistoryStateUpdated?.addListener?.(onHistoryStateUpdated);
} catch {}
chrome.tabs.onUpdated.addListener(onUpdated);
timer = setTimeout(finish, sniffMs);
});
} catch {}
};
try {
if (!hasDag) {
// Linear execution (legacy steps)
for (const step of steps) {
const t0 = Date.now();
ensureWithinDeadline();
const maxRetries = Math.max(0, (step as any).retry?.count ?? 0);
const baseInterval = Math.max(0, (step as any).retry?.intervalMs ?? 0);
let attempt = 0;
const doDelay = async (i: number) => {
const delay =
baseInterval > 0
? (step as any).retry?.backoff === 'exp'
? baseInterval * Math.pow(2, i)
: baseInterval
: 0;
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
};
while (true) {
try {
const beforeInfo = await getActiveTabInfo();
// special handling for script when=after: defer
if (step.type === 'script' && (step as any).when === 'after') {
pendingAfterScripts.push(step as any);
logs.push({ stepId: step.id, status: 'success', tookMs: Date.now() - t0 });
break;
}
const result = await executeStep(ctx, step);
// handle click/dblclick navigation/network-idle waits
if (step.type === 'click' || step.type === 'dblclick') {
const after = ((step as any).after || {}) as any;
if (after.waitForNavigation)
await waitForNavigationDone(beforeInfo.url, (step as any).timeoutMs);
else if (after.waitForNetworkIdle)
await waitForNetworkIdle(Math.min((step as any).timeoutMs || 5000, 120000), 1200);
else await maybeQuickWaitForNav(beforeInfo.url, (step as any).timeoutMs);
}
if (step.type === 'navigate' || step.type === 'openTab') {
await waitForNavigationDone(beforeInfo.url, (step as any).timeoutMs);
await ensureReadPageIfWeb();
} else if (step.type === 'switchTab') {
await ensureReadPageIfWeb();
}
if (!result?.alreadyLogged) {
logs.push({ stepId: step.id, status: 'success', tookMs: Date.now() - t0 });
}
await appendOverlay(`✔ ${step.type} (${step.id})`);
// control flows
if (result?.control) {
if (result.control.kind === 'foreach') {
const list = Array.isArray(vars[result.control.listVar])
? (vars[result.control.listVar] as any[])
: [];
for (const it of list) {
vars[result.control.itemVar] = it;
await runSubflowById(result.control.subflowId);
}
} else if (result.control.kind === 'while') {
let i = 0;
while (
i < result.control.maxIterations &&
evalCondition(result.control.condition)
) {
await runSubflowById(result.control.subflowId);
i++;
}
}
}
if (result?.deferAfterScript) pendingAfterScripts.push(result.deferAfterScript);
// run any deferred after-scripts
if (pendingAfterScripts.length > 0) {
while (pendingAfterScripts.length) {
const s = pendingAfterScripts.shift()!;
const tScript = Date.now();
const world = (s as any).world || 'ISOLATED';
const code = String((s as any).code || '');
if (code.trim()) {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (userCode: string) => {
try {
return (0, eval)(userCode);
} catch {
return null;
}
},
args: [code],
world: world as any,
} as any);
if ((s as any).saveAs) vars[(s as any).saveAs] = result;
if ((s as any).assign && typeof (s as any).assign === 'object')
applyAssign(vars, result, (s as any).assign);
}
logs.push({ stepId: s.id, status: 'success', tookMs: Date.now() - tScript });
}
}
break; // success
} catch (e: any) {
if (attempt < maxRetries) {
logs.push({ stepId: step.id, status: 'retrying', message: e?.message || String(e) });
await doDelay(attempt);
attempt += 1;
continue;
}
failed++;
logs.push({
stepId: step.id,
status: 'failed',
message: e?.message || String(e),
tookMs: Date.now() - t0,
});
await appendOverlay(`✘ ${step.type} (${step.id}) -> ${e?.message || String(e)}`);
if ((step as any).screenshotOnFail !== false) {
try {
const shot = await handleCallTool({
name: TOOL_NAMES.BROWSER.COMPUTER,
args: { action: 'screenshot' },
});
const img = (shot?.content?.find((c: any) => c.type === 'image') as any)
?.data as string;
if (img) logs[logs.length - 1].screenshotBase64 = img;
} catch {}
}
throw e;
}
}
}
} else {
// Graph traversal execution (DAG)
const id2node = new Map(nodes.map((n: any) => [n.id, n] as const));
const outEdges = new Map<string, Array<any>>();
for (const e of edges) {
if (!outEdges.has(e.from)) outEdges.set(e.from, []);
outEdges.get(e.from)!.push(e);
}
const indeg = new Map<string, number>(nodes.map((n: any) => [n.id, 0] as const));
for (const e of edges) indeg.set(e.to, (indeg.get(e.to) || 0) + 1);
let currentId =
options.startNodeId && id2node.has(options.startNodeId)
? options.startNodeId
: nodes.find((n: any) => (indeg.get(n.id) || 0) === 0)?.id || nodes[0]?.id;
let guard = 0;
while (currentId && guard++ < 10000) {
ensureWithinDeadline();
const node = id2node.get(currentId);
if (!node) break;
const step: any = mapDagNodeToStep(node as any);
const t0 = Date.now();
const maxRetries = Math.max(0, (step as any).retry?.count ?? 0);
const baseInterval = Math.max(0, (step as any).retry?.intervalMs ?? 0);
let attempt = 0;
const doDelay = async (i: number) => {
const delay =
baseInterval > 0
? (step as any).retry?.backoff === 'exp'
? baseInterval * Math.pow(2, i)
: baseInterval
: 0;
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
};
const beforeInfo = await getActiveTabInfo();
let jumpedOnError = false;
try {
const result = await executeStep(ctx, step);
if (step.type === 'click' || step.type === 'dblclick') {
const after = ((step as any).after || {}) as any;
if (after.waitForNavigation)
await waitForNavigationDone(beforeInfo.url, (step as any).timeoutMs);
else if (after.waitForNetworkIdle)
await waitForNetworkIdle(Math.min((step as any).timeoutMs || 5000, 120000), 1200);
else await maybeQuickWaitForNav(beforeInfo.url, (step as any).timeoutMs);
}
if (step.type === 'navigate' || step.type === 'openTab') {
await waitForNavigationDone(beforeInfo.url, (step as any).timeoutMs);
await ensureReadPageIfWeb();
} else if (step.type === 'switchTab') {
await ensureReadPageIfWeb();
}
if (!result?.alreadyLogged)
logs.push({ stepId: step.id, status: 'success', tookMs: Date.now() - t0 });
await appendOverlay(`✔ ${step.type} (${step.id})`);
// choose next by label
let nextLabel: string = 'default';
if (result?.nextLabel) nextLabel = String(result.nextLabel);
const oes = (outEdges.get(currentId) || []) as any[];
const edge =
oes.find((e) => String(e.label || 'default') === nextLabel) ||
oes.find((e) => !e.label || e.label === 'default');
currentId = edge ? edge.to : undefined;
} catch (e: any) {
if (attempt < maxRetries) {
logs.push({ stepId: step.id, status: 'retrying', message: e?.message || String(e) });
await doDelay(attempt);
attempt += 1;
continue;
}
logs.push({
stepId: step.id,
status: 'failed',
message: e?.message || String(e),
tookMs: Date.now() - t0,
});
await appendOverlay(`✘ ${step.type} (${step.id}) -> ${e?.message || String(e)}`);
if ((step as any).screenshotOnFail !== false) {
try {
const shot = await handleCallTool({
name: TOOL_NAMES.BROWSER.COMPUTER,
args: { action: 'screenshot' },
});
const img = (shot?.content?.find((c: any) => c.type === 'image') as any)
?.data as string;
if (img) logs[logs.length - 1].screenshotBase64 = img;
} catch {}
}
// onError jump
const oes = (outEdges.get(currentId) || []) as any[];
const errEdge = oes.find((e) => e.label === 'onError');
if (errEdge) {
currentId = errEdge.to;
jumpedOnError = true;
} else {
throw e;
}
}
if (!jumpedOnError) {
// flush deferred after-scripts
if (pendingAfterScripts.length > 0) {
while (pendingAfterScripts.length) {
const s = pendingAfterScripts.shift()!;
const tScript = Date.now();
const world = (s as any).world || 'ISOLATED';
const code = String((s as any).code || '');
if (code.trim()) {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (userCode: string) => {
try {
return (0, eval)(userCode);
} catch {
return null;
}
},
args: [code],
world: world as any,
} as any);
if ((s as any).saveAs) vars[(s as any).saveAs] = result;
if ((s as any).assign && typeof (s as any).assign === 'object')
applyAssign(vars, result, (s as any).assign);
}
logs.push({ stepId: s.id, status: 'success', tookMs: Date.now() - tScript });
}
}
}
}
}
} finally {
if (networkCaptureStarted) await stopAndSummarizeNetwork();
}
const tookMs = Date.now() - startAt;
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (tabs[0]?.id)
await chrome.tabs.sendMessage(tabs[0].id, { action: 'rr_overlay', cmd: 'done' } as any);
} catch {}
const record: RunRecord = {
id: runId,
flowId: flow.id,
startedAt: new Date(startAt).toISOString(),
finishedAt: new Date().toISOString(),
success: failed === 0,
entries: logs,
};
await appendRun(record);
// outputs: filter sensitive variables
const sensitiveKeys = new Set(
(flow.variables || []).filter((v) => v.sensitive).map((v) => v.key),
);
const outputs: Record<string, any> = {};
for (const [k, v] of Object.entries(vars)) if (!sensitiveKeys.has(k)) outputs[k] = v;
return {
runId,
success: failed === 0,
summary: { total: steps.length, success: steps.length - failed, failed, tookMs },
url: null,
outputs,
logs: options.returnLogs ? logs : undefined,
screenshots: { onFailure: logs.find((l) => l.status === 'failed')?.screenshotBase64 },
};
}
@@ -301,4 +301,5 @@ export interface RunResult {
outputs?: Record<string, any> | null;
logs?: RunLogEntry[];
screenshots?: { onFailure?: string | null };
paused?: boolean; // when true, the run was intentionally paused (e.g., breakpoint)
}
@@ -16,6 +16,7 @@ interface ClickToolParams {
waitForNavigation?: boolean; // Whether to wait for navigation to complete after click
timeout?: number; // Timeout in milliseconds for waiting for the element or navigation
frameId?: number; // Target frame for ref/selector resolution
double?: boolean; // Perform double click when true
}
/**
@@ -68,6 +69,7 @@ class ClickTool extends BaseBrowserToolExecutor {
ref: args.ref,
waitForNavigation,
timeout,
double: args.double === true,
},
frameId,
);
@@ -22,6 +22,7 @@ if (window.__CLICK_HELPER_INITIALIZED__) {
timeout = 5000,
coordinates = null,
ref = null,
double = false,
) {
try {
let element = null;
@@ -179,9 +180,14 @@ if (window.__CLICK_HELPER_INITIALIZED__) {
element &&
(elementInfo.clickMethod === 'selector' || elementInfo.clickMethod === 'ref')
) {
element.click();
if (double) {
simulateDomDoubleClick(element, clickX, clickY);
} else {
element.click();
}
} else {
simulateClick(clickX, clickY);
if (double) simulateDoubleClick(clickX, clickY);
else simulateClick(clickX, clickY);
}
// Wait for navigation if needed
@@ -226,6 +232,52 @@ if (window.__CLICK_HELPER_INITIALIZED__) {
}
}
/**
* Simulate a double click sequence at specific coordinates
*/
function simulateDoubleClick(x, y) {
simulateClick(x, y);
setTimeout(() => {
simulateClick(x, y);
const dbl = new MouseEvent('dblclick', {
view: window,
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
});
const el = document.elementFromPoint(x, y);
if (el) el.dispatchEvent(dbl);
else document.dispatchEvent(dbl);
}, 30);
}
/**
* Simulate double click using element when available
*/
function simulateDomDoubleClick(element, x, y) {
try {
element.click();
setTimeout(() => {
element.click();
const rect = element.getBoundingClientRect();
const cx = x ?? rect.left + rect.width / 2;
const cy = y ?? rect.top + rect.height / 2;
const dbl = new MouseEvent('dblclick', {
view: window,
bubbles: true,
cancelable: true,
clientX: cx,
clientY: cy,
});
element.dispatchEvent(dbl);
}, 30);
} catch (e) {
// fallback to coordinates
simulateDoubleClick(x, y);
}
}
/**
* Check if an element is visible
* @param {Element} element - The element to check
@@ -271,6 +323,7 @@ if (window.__CLICK_HELPER_INITIALIZED__) {
request.timeout,
request.coordinates,
request.ref,
!!request.double,
)
.then(sendResponse)
.catch((error) => {
+1 -1
View File
@@ -210,7 +210,7 @@
- Automa 块清单:`other/automa/src/utils/shared.js`
- Automa 典型 Handler:`other/automa/src/content/blocksHandler/*`、`other/automa/src/workflowEngine/blocksHandler/*`
- 本实现执行层:`app/chrome-extension/entrypoints/background/record-replay/*`(`runner.ts`、`node-registry.ts`、`selector-engine.ts`、`rr-utils.ts`)
- 本实现执行层:`app/chrome-extension/entrypoints/background/record-replay/*`(`engine/*`、`nodes/*`、`selector-engine.ts`、`rr-utils.ts`)
- 浏览器工具层:`app/chrome-extension/entrypoints/background/tools/browser/*`
- 录制/注入:`app/chrome-extension/inject-scripts/*`
- Builder 相关:`app/chrome-extension/entrypoints/popup/components/builder/*`
+17
View File
@@ -1679,3 +1679,20 @@ class RecordingStateManager {
clear(): Promise<void>;
}
```
---
## 回放重构变更(DAG-only)
本次回放执行改为仅支持 DAG(nodes + edges + subflows),弃用线性 `steps[]` 路径:
- 破坏性变更:当 Flow 不含 `nodes` 时,运行器返回错误 `dag-required`,提示迁移。
- 双击修复:`dblclick` 统一通过 CLICK 工具(内容脚本),支持 `frameId`,iframe 内双击准确。
- 控制流:
- 条件节点通过 `nextLabel` 选择分支。
- foreach/while 节点通过返回 `control` 调用 `flow.subflows[subflowId]` 作为循环体(DAG 路径已实现)。
迁移建议:
- 使用构建器的节点画布保存 `nodes/edges`(现有 UI 已支持)。
- 旧 Flow 若仅存 `steps[]`:通过现有 `stepsToNodes()` 转换为 `nodes/edges` 并保存。涉及循环的步骤需迁移为节点,并在 `flow.subflows` 内定义子流体。