Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/workflow-nodes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Workflow browser nodes (M3.8)

`@tronbrowser/workflow-engine` gains **browser nodes** that drive automation from
a workflow graph, using the **same SDK primitives** as the CLI and MCP.

A single `browser` node dispatches on its `action`:

```ts
import { runWorkflow, browserHandlers, sdkBrowser, type Workflow } from '@tronbrowser/workflow-engine';

const workflow: Workflow = {
id: 'leads', name: 'Scrape leads', entry: 'open',
nodes: {
open: { id: 'open', type: 'browser', next: ['links'], config: { action: 'open', url: 'https://example.com' } },
links: { id: 'links', type: 'browser', next: [], config: { action: 'extract', mode: 'links' } },
},
};

const browser = sdkBrowser({ headless: true });
const result = await runWorkflow(workflow, { variables: {} }, browserHandlers(browser));
console.log(exportWorkflowJson(result)); // JSON output
```

## Actions (`config.action`)

| action | config | output |
| --- | --- | --- |
| `open` | `url` | snapshot |
| `snapshot` | — | snapshot |
| `click` | `ref` | fresh snapshot |
| `fill` / `type` | `ref`, `value` | fresh snapshot |
| `extract` | `mode` (text\|links\|forms\|tables\|main\|selector) | JSON |
| `screenshot` | `path` | `{ screenshot: path }` |
| `analyze` | `goal?`, `data?`, `execute?` | AnalyzeResult |
| `runTask` | `goal`, `data?` | AnalyzeResult |

All actions run on **one shared managed session** for the workflow, opened lazily
and closed when the run ends (`onClose`).

## Runner

`runWorkflow(workflow, ctx, options)` walks the graph from `entry`, running one
handler per node and threading each output into `ctx.variables[nodeId]` (so later
nodes can reference earlier results). It stops at the first failing node.

- **JSON export**: `exportWorkflowJson(result)` (PRD §22).
- **Failure + recovery**: a failed node yields
`{ nodeId, error, recovery: { recoverable, suggestion } }`. A stale ref, for
example, suggests inserting a `browser.snapshot` node to refresh refs.

## Scope

- Browser nodes reuse `@tronbrowser/sdk` (`Browser`/`Page`).
- The runner is linear-first (follows `next[0]`); richer branching (conditional
nodes) builds on the same `NodeHandler` contract.
- Nodes: `packages/workflow-engine/src/nodes/browser.ts`; runner: `runner.ts`.
3 changes: 3 additions & 0 deletions packages/workflow-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"test": "vitest run --passWithNoTests",
"lint": "eslint src"
},
"dependencies": {
"@tronbrowser/sdk": "workspace:*"
},
"devDependencies": {
"typescript": "^5.6.3",
"vitest": "^2.1.4"
Expand Down
30 changes: 29 additions & 1 deletion packages/workflow-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,31 @@ interface NodeBase<T extends NodeType, C> {
}

export type PromptNode = NodeBase<'prompt', { template: string }>;
export type BrowserNode = NodeBase<'browser', { action: string; url?: string; selector?: string }>;

/** Browser node actions map to SDK Page primitives (PRD M3.8 / §21.1). */
export type BrowserAction =
| 'open'
| 'snapshot'
| 'click'
| 'fill'
| 'type'
| 'extract'
| 'screenshot'
| 'analyze'
| 'runTask';

export interface BrowserNodeConfig {
action: BrowserAction;
url?: string; // open
ref?: string; // click/fill/type
value?: string; // fill/type
mode?: string; // extract (text|links|forms|tables|main|selector)
path?: string; // screenshot destination
goal?: string; // analyze/runTask
data?: Record<string, unknown>; // analyze/runTask
execute?: boolean; // analyze
}
export type BrowserNode = NodeBase<'browser', BrowserNodeConfig>;
export type AiNode = NodeBase<'ai', { provider: string; model: string; system?: string }>;
export type HttpNode = NodeBase<'http', { method: string; url: string; body?: unknown }>;
export type ConditionalNode = NodeBase<'conditional', { expression: string; whenTrue: string; whenFalse: string }>;
Expand Down Expand Up @@ -70,3 +94,7 @@ export interface NodeHandler<N extends WorkflowNode = WorkflowNode> {
export interface WorkflowRunner {
run(workflow: Workflow, ctx: ExecutionContext): Promise<Record<string, NodeResult>>;
}

// Browser workflow nodes + runner (PRD M3.8).
export * from './nodes/browser.js';
export * from './runner.js';
84 changes: 84 additions & 0 deletions packages/workflow-engine/src/nodes/browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import type { BrowserNode, ExecutionContext } from '../index.js';
import { BrowserNodeError, createBrowserNodeHandler, type WorkflowBrowser, type WorkflowPage } from './browser.js';

const SNAP = { url: 'https://x/', title: 'X', timestamp: 't', elements: [] };

function fakePage() {
const calls: string[] = [];
const page: WorkflowPage = {
goto: async (u) => { calls.push('goto:' + u); },
snapshot: async () => SNAP,
click: async (r) => { calls.push('click:' + r); },
fill: async (r, v) => { calls.push('fill:' + r + '=' + v); },
extract: async (m) => ({ mode: m }),
screenshot: async () => Buffer.from('PNG'),
analyze: async (g) => ({ ok: true, mode: 'dry-run', status: 'planned', page: { url: 'x', title: 'X' }, ...(g ? { goal: g } : {}) }),
runTask: async () => ({ ok: true, mode: 'execute', status: 'complete', page: { url: 'x', title: 'X' } }),
};
return { page, calls };
}

function fakeBrowser(page: WorkflowPage) {
const writes: Array<{ path: string; bytes: Uint8Array }> = [];
let closed = false;
const browser: WorkflowBrowser = {
page: async () => page,
writeBytes: async (path, bytes) => { writes.push({ path, bytes }); },
close: async () => { closed = true; },
};
return { browser, writes, isClosed: () => closed };
}

const ctx: ExecutionContext = { variables: {} };
const node = (config: BrowserNode['config']): BrowserNode => ({ id: 'n', type: 'browser', next: [], config });

describe('browser node handler', () => {
it('open navigates and outputs a snapshot', async () => {
const { page, calls } = fakePage();
const h = createBrowserNodeHandler(fakeBrowser(page).browser);
const r = await h.run(node({ action: 'open', url: 'https://x/' }), ctx);
expect(calls).toContain('goto:https://x/');
expect((r.output as typeof SNAP).title).toBe('X');
});

it('click and fill act then output a snapshot', async () => {
const { page, calls } = fakePage();
const h = createBrowserNodeHandler(fakeBrowser(page).browser);
await h.run(node({ action: 'click', ref: '@e1' }), ctx);
await h.run(node({ action: 'fill', ref: '@e2', value: 'hi' }), ctx);
expect(calls).toEqual(expect.arrayContaining(['click:@e1', 'fill:@e2=hi']));
});

it('extract outputs data', async () => {
const { page } = fakePage();
const h = createBrowserNodeHandler(fakeBrowser(page).browser);
const r = await h.run(node({ action: 'extract', mode: 'links' }), ctx);
expect(r.output).toEqual({ mode: 'links' });
});

it('screenshot writes the file and outputs its path', async () => {
const { page } = fakePage();
const fb = fakeBrowser(page);
const h = createBrowserNodeHandler(fb.browser);
const r = await h.run(node({ action: 'screenshot', path: 'out.png' }), ctx);
expect(fb.writes[0].path).toBe('out.png');
expect(r.output).toEqual({ screenshot: 'out.png' });
});

it('analyze outputs a dry-run result', async () => {
const { page } = fakePage();
const h = createBrowserNodeHandler(fakeBrowser(page).browser);
const r = await h.run(node({ action: 'analyze', goal: 'Fill form' }), ctx);
expect((r.output as { status: string; goal: string }).status).toBe('planned');
expect((r.output as { goal: string }).goal).toBe('Fill form');
});

it('throws a non-recoverable BrowserNodeError when a required field is missing', async () => {
const { page } = fakePage();
const h = createBrowserNodeHandler(fakeBrowser(page).browser);
const err = await h.run(node({ action: 'click' }), ctx).catch((e) => e);
expect(err).toBeInstanceOf(BrowserNodeError);
expect(err.recovery.recoverable).toBe(false);
});
});
128 changes: 128 additions & 0 deletions packages/workflow-engine/src/nodes/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Browser workflow nodes (PRD M3.8 / §21.1). A single BrowserNode handler
* dispatches its `action` to the same SDK Page primitives used by the CLI and
* MCP — so open/snapshot/click/fill/extract/screenshot/analyze/runTask all share
* one implementation. A failed node surfaces recovery details.
*/
import type { AgentSnapshot, AnalyzeOptions, AnalyzeResult, LaunchOptions } from '@tronbrowser/sdk';
import { tron } from '@tronbrowser/sdk';
import { writeFile } from 'node:fs/promises';
import type { BrowserNode, ExecutionContext, NodeHandler, NodeResult } from '../index.js';

/** The subset of the SDK Page a browser node uses (fakeable in tests). */
export interface WorkflowPage {
goto(url: string): Promise<void>;
snapshot(): Promise<AgentSnapshot>;
click(ref: string): Promise<void>;
fill(ref: string, value: string): Promise<void>;
extract(target: string): Promise<unknown>;
screenshot(): Promise<Uint8Array>;
analyze(goal?: string, options?: AnalyzeOptions): Promise<AnalyzeResult>;
runTask(goal: string, options?: AnalyzeOptions): Promise<AnalyzeResult>;
}

/** A browser the workflow shares across its browser nodes. */
export interface WorkflowBrowser {
page(): Promise<WorkflowPage>;
writeBytes(path: string, bytes: Uint8Array): Promise<void>;
close(): Promise<void>;
}

export interface Recovery {
recoverable: boolean;
suggestion: string;
}

/** Error from a browser node, carrying recovery guidance for the runner. */
export class BrowserNodeError extends Error {
readonly recovery: Recovery;
constructor(message: string, recovery: Recovery) {
super(message);
this.name = 'BrowserNodeError';
this.recovery = recovery;
}
}

function require(value: string | undefined, field: string, action: string): string {
if (value === undefined || value === '') {
throw new BrowserNodeError(`browser.${action} requires "${field}"`, {
recoverable: false,
suggestion: `Set config.${field} on the browser node.`,
});
}
return value;
}

/** Default WorkflowBrowser backed by a managed SDK session (lazy). */
export function sdkBrowser(options: LaunchOptions = {}): WorkflowBrowser {
let launched: { page: WorkflowPage; close: () => Promise<void> } | undefined;
return {
async page() {
if (!launched) {
const browser = await tron.launch(options);
const page = (await browser.newPage()) as unknown as WorkflowPage;
launched = { page, close: () => browser.close() };
}
return launched.page;
},
writeBytes: (path, bytes) => writeFile(path, bytes),
async close() {
await launched?.close();
launched = undefined;
},
};
}

/** Create the browser node handler bound to a shared browser. */
export function createBrowserNodeHandler(browser: WorkflowBrowser): NodeHandler<BrowserNode> {
return {
type: 'browser',
async run(node: BrowserNode, _ctx: ExecutionContext): Promise<NodeResult> {
const c = node.config;
const page = await browser.page();
let output: unknown;
switch (c.action) {
case 'open':
await page.goto(require(c.url, 'url', 'open'));
output = await page.snapshot();
break;
case 'snapshot':
output = await page.snapshot();
break;
case 'click':
await page.click(require(c.ref, 'ref', 'click'));
output = await page.snapshot();
break;
case 'fill':
case 'type':
await page.fill(require(c.ref, 'ref', c.action), c.value ?? '');
output = await page.snapshot();
break;
case 'extract':
output = await page.extract(c.mode ?? 'text');
break;
case 'screenshot': {
const path = require(c.path, 'path', 'screenshot');
await browser.writeBytes(path, await page.screenshot());
output = { screenshot: path };
break;
}
case 'analyze':
output = await page.analyze(c.goal, {
...(c.data ? { data: c.data } : {}),
...(c.execute ? { execute: true } : {}),
});
break;
case 'runTask':
output = await page.runTask(require(c.goal, 'goal', 'runTask'), c.data ? { data: c.data } : {});
break;
default:
throw new BrowserNodeError(`unknown browser action: ${String((c as { action: string }).action)}`, {
recoverable: false,
suggestion: 'Use one of: open, snapshot, click, fill, extract, screenshot, analyze, runTask.',
});
}
return { nodeId: node.id, output, next: node.next };
},
};
}
Loading
Loading