Skip to content

Commit a269fcd

Browse files
ralyodioclaude
andauthored
feat(m3.8): browser workflow nodes (#35)
Adds browser nodes to @tronbrowser/workflow-engine (PRD §21.1), driving automation from a workflow graph with the same SDK primitives as the CLI/MCP. - nodes/browser.ts: one BrowserNode handler dispatching config.action -> open/snapshot/click/fill/type/extract/screenshot/analyze/runTask on a shared SDK page. Mutating actions output a fresh snapshot; screenshot writes the file and outputs its path. Missing required config throws a BrowserNodeError with recovery guidance. sdkBrowser() is the default managed-session backing. - runner.ts: runWorkflow walks the graph from entry, threads each output into ctx.variables[nodeId], and closes the browser on completion. exportWorkflowJson serializes results; a failed node reports { nodeId, error, recovery } (e.g. a stale ref suggests inserting a snapshot node). - index.ts: richer BrowserNodeConfig (action + url/ref/value/mode/path/goal/data). Tests (+10): every browser action against a fake page, and the runner (graph execution + output threading + browser close, JSON export, failure with recovery, missing-handler). Full workspace suite green in CI order. Completes the M3 milestones (M3.1–M3.8). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b6d5e98 commit a269fcd

8 files changed

Lines changed: 483 additions & 1 deletion

File tree

docs/workflow-nodes.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Workflow browser nodes (M3.8)
2+
3+
`@tronbrowser/workflow-engine` gains **browser nodes** that drive automation from
4+
a workflow graph, using the **same SDK primitives** as the CLI and MCP.
5+
6+
A single `browser` node dispatches on its `action`:
7+
8+
```ts
9+
import { runWorkflow, browserHandlers, sdkBrowser, type Workflow } from '@tronbrowser/workflow-engine';
10+
11+
const workflow: Workflow = {
12+
id: 'leads', name: 'Scrape leads', entry: 'open',
13+
nodes: {
14+
open: { id: 'open', type: 'browser', next: ['links'], config: { action: 'open', url: 'https://example.com' } },
15+
links: { id: 'links', type: 'browser', next: [], config: { action: 'extract', mode: 'links' } },
16+
},
17+
};
18+
19+
const browser = sdkBrowser({ headless: true });
20+
const result = await runWorkflow(workflow, { variables: {} }, browserHandlers(browser));
21+
console.log(exportWorkflowJson(result)); // JSON output
22+
```
23+
24+
## Actions (`config.action`)
25+
26+
| action | config | output |
27+
| --- | --- | --- |
28+
| `open` | `url` | snapshot |
29+
| `snapshot` || snapshot |
30+
| `click` | `ref` | fresh snapshot |
31+
| `fill` / `type` | `ref`, `value` | fresh snapshot |
32+
| `extract` | `mode` (text\|links\|forms\|tables\|main\|selector) | JSON |
33+
| `screenshot` | `path` | `{ screenshot: path }` |
34+
| `analyze` | `goal?`, `data?`, `execute?` | AnalyzeResult |
35+
| `runTask` | `goal`, `data?` | AnalyzeResult |
36+
37+
All actions run on **one shared managed session** for the workflow, opened lazily
38+
and closed when the run ends (`onClose`).
39+
40+
## Runner
41+
42+
`runWorkflow(workflow, ctx, options)` walks the graph from `entry`, running one
43+
handler per node and threading each output into `ctx.variables[nodeId]` (so later
44+
nodes can reference earlier results). It stops at the first failing node.
45+
46+
- **JSON export**: `exportWorkflowJson(result)` (PRD §22).
47+
- **Failure + recovery**: a failed node yields
48+
`{ nodeId, error, recovery: { recoverable, suggestion } }`. A stale ref, for
49+
example, suggests inserting a `browser.snapshot` node to refresh refs.
50+
51+
## Scope
52+
53+
- Browser nodes reuse `@tronbrowser/sdk` (`Browser`/`Page`).
54+
- The runner is linear-first (follows `next[0]`); richer branching (conditional
55+
nodes) builds on the same `NodeHandler` contract.
56+
- Nodes: `packages/workflow-engine/src/nodes/browser.ts`; runner: `runner.ts`.

packages/workflow-engine/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
"test": "vitest run --passWithNoTests",
1919
"lint": "eslint src"
2020
},
21+
"dependencies": {
22+
"@tronbrowser/sdk": "workspace:*"
23+
},
2124
"devDependencies": {
2225
"typescript": "^5.6.3",
2326
"vitest": "^2.1.4"

packages/workflow-engine/src/index.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,31 @@ interface NodeBase<T extends NodeType, C> {
2626
}
2727

2828
export type PromptNode = NodeBase<'prompt', { template: string }>;
29-
export type BrowserNode = NodeBase<'browser', { action: string; url?: string; selector?: string }>;
29+
30+
/** Browser node actions map to SDK Page primitives (PRD M3.8 / §21.1). */
31+
export type BrowserAction =
32+
| 'open'
33+
| 'snapshot'
34+
| 'click'
35+
| 'fill'
36+
| 'type'
37+
| 'extract'
38+
| 'screenshot'
39+
| 'analyze'
40+
| 'runTask';
41+
42+
export interface BrowserNodeConfig {
43+
action: BrowserAction;
44+
url?: string; // open
45+
ref?: string; // click/fill/type
46+
value?: string; // fill/type
47+
mode?: string; // extract (text|links|forms|tables|main|selector)
48+
path?: string; // screenshot destination
49+
goal?: string; // analyze/runTask
50+
data?: Record<string, unknown>; // analyze/runTask
51+
execute?: boolean; // analyze
52+
}
53+
export type BrowserNode = NodeBase<'browser', BrowserNodeConfig>;
3054
export type AiNode = NodeBase<'ai', { provider: string; model: string; system?: string }>;
3155
export type HttpNode = NodeBase<'http', { method: string; url: string; body?: unknown }>;
3256
export type ConditionalNode = NodeBase<'conditional', { expression: string; whenTrue: string; whenFalse: string }>;
@@ -70,3 +94,7 @@ export interface NodeHandler<N extends WorkflowNode = WorkflowNode> {
7094
export interface WorkflowRunner {
7195
run(workflow: Workflow, ctx: ExecutionContext): Promise<Record<string, NodeResult>>;
7296
}
97+
98+
// Browser workflow nodes + runner (PRD M3.8).
99+
export * from './nodes/browser.js';
100+
export * from './runner.js';
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { BrowserNode, ExecutionContext } from '../index.js';
3+
import { BrowserNodeError, createBrowserNodeHandler, type WorkflowBrowser, type WorkflowPage } from './browser.js';
4+
5+
const SNAP = { url: 'https://x/', title: 'X', timestamp: 't', elements: [] };
6+
7+
function fakePage() {
8+
const calls: string[] = [];
9+
const page: WorkflowPage = {
10+
goto: async (u) => { calls.push('goto:' + u); },
11+
snapshot: async () => SNAP,
12+
click: async (r) => { calls.push('click:' + r); },
13+
fill: async (r, v) => { calls.push('fill:' + r + '=' + v); },
14+
extract: async (m) => ({ mode: m }),
15+
screenshot: async () => Buffer.from('PNG'),
16+
analyze: async (g) => ({ ok: true, mode: 'dry-run', status: 'planned', page: { url: 'x', title: 'X' }, ...(g ? { goal: g } : {}) }),
17+
runTask: async () => ({ ok: true, mode: 'execute', status: 'complete', page: { url: 'x', title: 'X' } }),
18+
};
19+
return { page, calls };
20+
}
21+
22+
function fakeBrowser(page: WorkflowPage) {
23+
const writes: Array<{ path: string; bytes: Uint8Array }> = [];
24+
let closed = false;
25+
const browser: WorkflowBrowser = {
26+
page: async () => page,
27+
writeBytes: async (path, bytes) => { writes.push({ path, bytes }); },
28+
close: async () => { closed = true; },
29+
};
30+
return { browser, writes, isClosed: () => closed };
31+
}
32+
33+
const ctx: ExecutionContext = { variables: {} };
34+
const node = (config: BrowserNode['config']): BrowserNode => ({ id: 'n', type: 'browser', next: [], config });
35+
36+
describe('browser node handler', () => {
37+
it('open navigates and outputs a snapshot', async () => {
38+
const { page, calls } = fakePage();
39+
const h = createBrowserNodeHandler(fakeBrowser(page).browser);
40+
const r = await h.run(node({ action: 'open', url: 'https://x/' }), ctx);
41+
expect(calls).toContain('goto:https://x/');
42+
expect((r.output as typeof SNAP).title).toBe('X');
43+
});
44+
45+
it('click and fill act then output a snapshot', async () => {
46+
const { page, calls } = fakePage();
47+
const h = createBrowserNodeHandler(fakeBrowser(page).browser);
48+
await h.run(node({ action: 'click', ref: '@e1' }), ctx);
49+
await h.run(node({ action: 'fill', ref: '@e2', value: 'hi' }), ctx);
50+
expect(calls).toEqual(expect.arrayContaining(['click:@e1', 'fill:@e2=hi']));
51+
});
52+
53+
it('extract outputs data', async () => {
54+
const { page } = fakePage();
55+
const h = createBrowserNodeHandler(fakeBrowser(page).browser);
56+
const r = await h.run(node({ action: 'extract', mode: 'links' }), ctx);
57+
expect(r.output).toEqual({ mode: 'links' });
58+
});
59+
60+
it('screenshot writes the file and outputs its path', async () => {
61+
const { page } = fakePage();
62+
const fb = fakeBrowser(page);
63+
const h = createBrowserNodeHandler(fb.browser);
64+
const r = await h.run(node({ action: 'screenshot', path: 'out.png' }), ctx);
65+
expect(fb.writes[0].path).toBe('out.png');
66+
expect(r.output).toEqual({ screenshot: 'out.png' });
67+
});
68+
69+
it('analyze outputs a dry-run result', async () => {
70+
const { page } = fakePage();
71+
const h = createBrowserNodeHandler(fakeBrowser(page).browser);
72+
const r = await h.run(node({ action: 'analyze', goal: 'Fill form' }), ctx);
73+
expect((r.output as { status: string; goal: string }).status).toBe('planned');
74+
expect((r.output as { goal: string }).goal).toBe('Fill form');
75+
});
76+
77+
it('throws a non-recoverable BrowserNodeError when a required field is missing', async () => {
78+
const { page } = fakePage();
79+
const h = createBrowserNodeHandler(fakeBrowser(page).browser);
80+
const err = await h.run(node({ action: 'click' }), ctx).catch((e) => e);
81+
expect(err).toBeInstanceOf(BrowserNodeError);
82+
expect(err.recovery.recoverable).toBe(false);
83+
});
84+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Browser workflow nodes (PRD M3.8 / §21.1). A single BrowserNode handler
3+
* dispatches its `action` to the same SDK Page primitives used by the CLI and
4+
* MCP — so open/snapshot/click/fill/extract/screenshot/analyze/runTask all share
5+
* one implementation. A failed node surfaces recovery details.
6+
*/
7+
import type { AgentSnapshot, AnalyzeOptions, AnalyzeResult, LaunchOptions } from '@tronbrowser/sdk';
8+
import { tron } from '@tronbrowser/sdk';
9+
import { writeFile } from 'node:fs/promises';
10+
import type { BrowserNode, ExecutionContext, NodeHandler, NodeResult } from '../index.js';
11+
12+
/** The subset of the SDK Page a browser node uses (fakeable in tests). */
13+
export interface WorkflowPage {
14+
goto(url: string): Promise<void>;
15+
snapshot(): Promise<AgentSnapshot>;
16+
click(ref: string): Promise<void>;
17+
fill(ref: string, value: string): Promise<void>;
18+
extract(target: string): Promise<unknown>;
19+
screenshot(): Promise<Uint8Array>;
20+
analyze(goal?: string, options?: AnalyzeOptions): Promise<AnalyzeResult>;
21+
runTask(goal: string, options?: AnalyzeOptions): Promise<AnalyzeResult>;
22+
}
23+
24+
/** A browser the workflow shares across its browser nodes. */
25+
export interface WorkflowBrowser {
26+
page(): Promise<WorkflowPage>;
27+
writeBytes(path: string, bytes: Uint8Array): Promise<void>;
28+
close(): Promise<void>;
29+
}
30+
31+
export interface Recovery {
32+
recoverable: boolean;
33+
suggestion: string;
34+
}
35+
36+
/** Error from a browser node, carrying recovery guidance for the runner. */
37+
export class BrowserNodeError extends Error {
38+
readonly recovery: Recovery;
39+
constructor(message: string, recovery: Recovery) {
40+
super(message);
41+
this.name = 'BrowserNodeError';
42+
this.recovery = recovery;
43+
}
44+
}
45+
46+
function require(value: string | undefined, field: string, action: string): string {
47+
if (value === undefined || value === '') {
48+
throw new BrowserNodeError(`browser.${action} requires "${field}"`, {
49+
recoverable: false,
50+
suggestion: `Set config.${field} on the browser node.`,
51+
});
52+
}
53+
return value;
54+
}
55+
56+
/** Default WorkflowBrowser backed by a managed SDK session (lazy). */
57+
export function sdkBrowser(options: LaunchOptions = {}): WorkflowBrowser {
58+
let launched: { page: WorkflowPage; close: () => Promise<void> } | undefined;
59+
return {
60+
async page() {
61+
if (!launched) {
62+
const browser = await tron.launch(options);
63+
const page = (await browser.newPage()) as unknown as WorkflowPage;
64+
launched = { page, close: () => browser.close() };
65+
}
66+
return launched.page;
67+
},
68+
writeBytes: (path, bytes) => writeFile(path, bytes),
69+
async close() {
70+
await launched?.close();
71+
launched = undefined;
72+
},
73+
};
74+
}
75+
76+
/** Create the browser node handler bound to a shared browser. */
77+
export function createBrowserNodeHandler(browser: WorkflowBrowser): NodeHandler<BrowserNode> {
78+
return {
79+
type: 'browser',
80+
async run(node: BrowserNode, _ctx: ExecutionContext): Promise<NodeResult> {
81+
const c = node.config;
82+
const page = await browser.page();
83+
let output: unknown;
84+
switch (c.action) {
85+
case 'open':
86+
await page.goto(require(c.url, 'url', 'open'));
87+
output = await page.snapshot();
88+
break;
89+
case 'snapshot':
90+
output = await page.snapshot();
91+
break;
92+
case 'click':
93+
await page.click(require(c.ref, 'ref', 'click'));
94+
output = await page.snapshot();
95+
break;
96+
case 'fill':
97+
case 'type':
98+
await page.fill(require(c.ref, 'ref', c.action), c.value ?? '');
99+
output = await page.snapshot();
100+
break;
101+
case 'extract':
102+
output = await page.extract(c.mode ?? 'text');
103+
break;
104+
case 'screenshot': {
105+
const path = require(c.path, 'path', 'screenshot');
106+
await browser.writeBytes(path, await page.screenshot());
107+
output = { screenshot: path };
108+
break;
109+
}
110+
case 'analyze':
111+
output = await page.analyze(c.goal, {
112+
...(c.data ? { data: c.data } : {}),
113+
...(c.execute ? { execute: true } : {}),
114+
});
115+
break;
116+
case 'runTask':
117+
output = await page.runTask(require(c.goal, 'goal', 'runTask'), c.data ? { data: c.data } : {});
118+
break;
119+
default:
120+
throw new BrowserNodeError(`unknown browser action: ${String((c as { action: string }).action)}`, {
121+
recoverable: false,
122+
suggestion: 'Use one of: open, snapshot, click, fill, extract, screenshot, analyze, runTask.',
123+
});
124+
}
125+
return { nodeId: node.id, output, next: node.next };
126+
},
127+
};
128+
}

0 commit comments

Comments
 (0)