diff --git a/docs/workflow-nodes.md b/docs/workflow-nodes.md new file mode 100644 index 0000000..d581464 --- /dev/null +++ b/docs/workflow-nodes.md @@ -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`. diff --git a/packages/workflow-engine/package.json b/packages/workflow-engine/package.json index 1a20c2f..7f15b54 100644 --- a/packages/workflow-engine/package.json +++ b/packages/workflow-engine/package.json @@ -18,6 +18,9 @@ "test": "vitest run --passWithNoTests", "lint": "eslint src" }, + "dependencies": { + "@tronbrowser/sdk": "workspace:*" + }, "devDependencies": { "typescript": "^5.6.3", "vitest": "^2.1.4" diff --git a/packages/workflow-engine/src/index.ts b/packages/workflow-engine/src/index.ts index 4c75247..87e9056 100644 --- a/packages/workflow-engine/src/index.ts +++ b/packages/workflow-engine/src/index.ts @@ -26,7 +26,31 @@ interface NodeBase { } 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; // 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 }>; @@ -70,3 +94,7 @@ export interface NodeHandler { export interface WorkflowRunner { run(workflow: Workflow, ctx: ExecutionContext): Promise>; } + +// Browser workflow nodes + runner (PRD M3.8). +export * from './nodes/browser.js'; +export * from './runner.js'; diff --git a/packages/workflow-engine/src/nodes/browser.test.ts b/packages/workflow-engine/src/nodes/browser.test.ts new file mode 100644 index 0000000..f3f32af --- /dev/null +++ b/packages/workflow-engine/src/nodes/browser.test.ts @@ -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); + }); +}); diff --git a/packages/workflow-engine/src/nodes/browser.ts b/packages/workflow-engine/src/nodes/browser.ts new file mode 100644 index 0000000..ae52d20 --- /dev/null +++ b/packages/workflow-engine/src/nodes/browser.ts @@ -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; + snapshot(): Promise; + click(ref: string): Promise; + fill(ref: string, value: string): Promise; + extract(target: string): Promise; + screenshot(): Promise; + analyze(goal?: string, options?: AnalyzeOptions): Promise; + runTask(goal: string, options?: AnalyzeOptions): Promise; +} + +/** A browser the workflow shares across its browser nodes. */ +export interface WorkflowBrowser { + page(): Promise; + writeBytes(path: string, bytes: Uint8Array): Promise; + close(): Promise; +} + +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 } | 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 { + return { + type: 'browser', + async run(node: BrowserNode, _ctx: ExecutionContext): Promise { + 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 }; + }, + }; +} diff --git a/packages/workflow-engine/src/runner.test.ts b/packages/workflow-engine/src/runner.test.ts new file mode 100644 index 0000000..879b361 --- /dev/null +++ b/packages/workflow-engine/src/runner.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import type { ExecutionContext, Workflow } from './index.js'; +import type { WorkflowBrowser, WorkflowPage } from './nodes/browser.js'; +import { browserHandlers, exportWorkflowJson, runWorkflow } from './runner.js'; + +const SNAP = { url: 'https://x/', title: 'X', timestamp: 't', elements: [] }; + +function browser(overrides: Partial = {}) { + let closed = false; + const page: WorkflowPage = { + goto: async () => {}, + snapshot: async () => SNAP, + click: async () => {}, + fill: async () => {}, + extract: async () => ({ links: [{ text: 'A', href: 'https://x/a' }] }), + screenshot: async () => Buffer.from('PNG'), + analyze: async () => ({ ok: true, mode: 'dry-run', status: 'planned', page: { url: 'x', title: 'X' } }), + runTask: async () => ({ ok: true, mode: 'execute', status: 'complete', page: { url: 'x', title: 'X' } }), + ...overrides, + }; + const b: WorkflowBrowser = { page: async () => page, writeBytes: async () => {}, close: async () => { closed = true; } }; + return { browser: b, isClosed: () => closed }; +} + +const workflow: Workflow = { + id: 'wf', name: 'lead scrape', entry: 'open', + nodes: { + open: { id: 'open', type: 'browser', next: ['grab'], config: { action: 'open', url: 'https://x/' } }, + grab: { id: 'grab', type: 'browser', next: [], config: { action: 'extract', mode: 'links' } }, + }, +}; + +function ctx(): ExecutionContext { + return { variables: {} }; +} + +describe('runWorkflow', () => { + it('runs the graph, threads outputs, and closes the browser', async () => { + const b = browser(); + const c = ctx(); + const result = await runWorkflow(workflow, c, browserHandlers(b.browser)); + expect(result.ok).toBe(true); + expect(result.results.open.output).toEqual(SNAP); + expect((result.results.grab.output as { links: unknown[] }).links).toHaveLength(1); + expect(c.variables.grab).toBeDefined(); // outputs threaded into context + expect(b.isClosed()).toBe(true); // onClose ran + }); + + it('exports the run result as JSON', () => { + const json = exportWorkflowJson({ ok: true, results: {} }); + expect(JSON.parse(json).ok).toBe(true); + }); + + it('stops on a failing node with recovery details, and still closes', async () => { + const b = browser({ + click: async () => { + throw Object.assign(new Error('Ref @e9 not found'), { code: 'STALE_REF', recoverable: true }); + }, + }); + const wf: Workflow = { + id: 'wf', name: 'x', entry: 'a', + nodes: { + a: { id: 'a', type: 'browser', next: ['b'], config: { action: 'open', url: 'https://x/' } }, + b: { id: 'b', type: 'browser', next: [], config: { action: 'click', ref: '@e9' } }, + }, + }; + const result = await runWorkflow(wf, ctx(), browserHandlers(b.browser)); + expect(result.ok).toBe(false); + expect(result.failure!.nodeId).toBe('b'); + expect(result.failure!.recovery.recoverable).toBe(true); + expect(result.failure!.recovery.suggestion).toMatch(/snapshot/i); + expect(result.results.a).toBeDefined(); // the first node still ran + expect(b.isClosed()).toBe(true); + }); + + it('fails cleanly when no handler is registered for a node type', async () => { + const wf: Workflow = { + id: 'wf', name: 'x', entry: 'a', + nodes: { a: { id: 'a', type: 'ai', next: [], config: { provider: 'anthropic', model: 'claude' } } }, + }; + const result = await runWorkflow(wf, ctx(), { handlers: new Map() }); + expect(result.ok).toBe(false); + expect(result.failure!.error).toMatch(/no handler/); + }); +}); diff --git a/packages/workflow-engine/src/runner.ts b/packages/workflow-engine/src/runner.ts new file mode 100644 index 0000000..24b3f66 --- /dev/null +++ b/packages/workflow-engine/src/runner.ts @@ -0,0 +1,94 @@ +/** + * Minimal workflow runner (PRD M3.8). Walks the graph from `entry`, invoking a + * handler per node, threading each output into `ctx.variables[nodeId]`. On a + * node failure it stops and reports recovery details; results export as JSON. + */ +import type { ExecutionContext, NodeHandler, NodeResult, NodeType, Workflow } from './index.js'; +import { BrowserNodeError, type Recovery, type WorkflowBrowser } from './nodes/browser.js'; +import { createBrowserNodeHandler } from './nodes/browser.js'; + +export interface NodeFailure { + nodeId: string; + error: string; + recovery: Recovery; + traceId?: string; +} + +export interface WorkflowRunResult { + ok: boolean; + results: Record; + failure?: NodeFailure; +} + +export interface RunOptions { + handlers: Map; + maxNodes?: number; + /** Called once the run ends (e.g. to close the browser session). */ + onClose?: () => Promise; +} + +function toRecovery(err: unknown): Recovery { + if (err instanceof BrowserNodeError) return err.recovery; + const e = err as { code?: string; recoverable?: boolean }; + if (e?.code === 'STALE_REF') { + return { recoverable: true, suggestion: 'Insert a browser.snapshot node before this action to refresh refs.' }; + } + return { recoverable: e?.recoverable === true, suggestion: 'Inspect the error and adjust the node config.' }; +} + +export async function runWorkflow( + workflow: Workflow, + ctx: ExecutionContext, + options: RunOptions, +): Promise { + const results: Record = {}; + const max = options.maxNodes ?? 100; + let current: string | undefined = workflow.entry; + let failure: NodeFailure | undefined; + let steps = 0; + + try { + while (current && steps < max) { + if (ctx.signal?.aborted) break; + const node = workflow.nodes[current]; + if (!node) break; + const handler = options.handlers.get(node.type); + if (!handler) { + failure = { + nodeId: node.id, + error: `no handler for node type "${node.type}"`, + recovery: { recoverable: false, suggestion: `Register a "${node.type}" handler.` }, + }; + break; + } + try { + const result = await handler.run(node, ctx); + results[node.id] = result; + ctx.variables[node.id] = result.output; + current = result.next[0]; + } catch (err) { + failure = { nodeId: node.id, error: (err as Error).message, recovery: toRecovery(err) }; + break; + } + steps += 1; + } + } finally { + await options.onClose?.(); + } + + return { ok: failure === undefined, results, ...(failure ? { failure } : {}) }; +} + +/** Convenience: handler map + onClose wired to a shared browser. */ +export function browserHandlers(browser: WorkflowBrowser): Pick { + const handler = createBrowserNodeHandler(browser); + return { + handlers: new Map([['browser', handler as NodeHandler]]), + onClose: () => browser.close(), + }; +} + +/** Export a run result as JSON (PRD §22: workflow outputs export as JSON). */ +export function exportWorkflowJson(result: WorkflowRunResult): string { + return JSON.stringify(result, null, 2); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4c447f..9c5e2a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,10 @@ importers: version: 2.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(lightningcss@1.32.0)(terser@5.48.0) packages/workflow-engine: + dependencies: + '@tronbrowser/sdk': + specifier: workspace:* + version: link:../sdk devDependencies: typescript: specifier: ^5.6.3