Skip to content

Latest commit

 

History

History
610 lines (513 loc) · 26.3 KB

File metadata and controls

610 lines (513 loc) · 26.3 KB

Workflow DSL v1alpha1

等待、悬停和滚动可使用受运行 Seed 控制的统一随机范围;鼠标、点击、输入和滚动节奏由 interactionProfiles 配置。字段与示例见 RANDOMIZED_INTERACTIONS.md

Every workflow has apiVersion, kind, metadata, and a spec:

apiVersion: browserweave.io/v1alpha1
kind: Workflow
metadata:
  name: example
spec:
  inputs: {}
  outputs: {}
  hooks: {}
  listeners: []
  webhooks: []
  nodes: {}
  edges: []

Parameters and values

Inputs support string, number, integer, boolean, object, and array. Strings additionally support URI validation, length constraints, enumeration, defaults, and secret redaction. A value originating from a secret: true input stays classified when copied into vars, steps, outputs, child-workflow outputs, or error text. Public snapshots and events replace exact and embedded occurrences with [REDACTED]; raw values remain available only inside the executor, and v0.1 does not provide a declassification escape hatch.

Template namespaces:

  • ${inputs.name} — immutable run input
  • ${vars.name} — mutable run variable
  • ${steps.nodeId.value} — completed node output
  • ${event.name} — current callback event
  • ${run.id} — run metadata

An exact template preserves the original value type. A template embedded inside other text is converted to a string.

Locators

Concise semantic locator:

locator:
  engine: js
  role: button
  name: Submit
  exact: true

Custom JavaScript locator with arguments:

locator:
  engine: js
  script: |
    function locate(args) {
      return [...document.querySelectorAll(args.selector)]
        .find(element => element.innerText.includes(args.text));
    }
  args:
    selector: article button
    text: "${inputs.buttonText}"

Arguments are serialized separately. They are not interpolated into JavaScript source.

Weighted candidates:

locator:
  engine: js
  candidates:
    - { type: testId, value: submit-comment, score: 100 }
    - { type: role, role: button, name: Submit, exact: true, score: 95 }
    - { type: attribute, tag: button, attributes: { type: submit }, score: 80 }
    - { type: css, value: "form button[type=submit]", score: 40 }

Nodes

Type Main with fields Output
start none empty
end none empty
navigate url URL
page.reload optional ignoreCache refreshed active tab
page.back / page.forward none active history entry ID, URL, and title
tab.open url new active tab ID, URL, and title
tab.switch urlContains and/or titleContains found switches to the unique tab; missing means zero matches; multiple matches remain a failure
tab.list optional saveAs all open page targets without switching
tab.close optional urlContains/titleContains closed or missing; omitted selector closes the active tab
dialog.handle action, optional type/message/prompt filters and timeout handled alert/confirm/prompt metadata
click locator resolved element metadata; target-width/Fitts timing, minimum-jerk CDP path, hit-test before press
hover locator resolved element metadata; target-aware minimum-jerk CDP path
drag.drop source, target, optional hold safe source and target metadata
scroll deltaX and/or deltaY, optional locator applied logical deltas; accelerated wheel frames, bounded jitter and corrected overshoot; a locator targets a nested scroll container, while omission scrolls the page
fill locator, value, optional clear element and input length; physical ASCII keys, IME-like Unicode commits and bounded pauses
select locator, values, optional mode (value or label) element and selected native option values
check locator, strict boolean checked element, final state, and whether a click changed it
press locator, keys, optional repeat element, chord request, and repeat count
upload locator, files (absolute browser-host paths) safe element metadata, file count, and whether the input accepts multiple files
dom.check `mode: any all, 1–64 declarative conditions, optional negate/saveAs`
extract locator, property, saveAs extracted value
page.extract optional locator, format, maxChars, saveAs bounded content in vars; only safe metadata in step output
extract.list optional root, literal itemSelector, 1–32 fields, bounds, saveAs structured item array in vars; only count/truncation metadata in step output
model.invoke model connection, operation, arbitrary typed input, optional structured output, saveAs text or typed JSON value
wait duration or DOM locator/state/text/stableFor matched element
network.wait match, timeout, optional handler/saveAs HTTP exchange
download.prepare absolute browser-host path, optional namingMode starts a clean run-scoped download observation window
download.wait optional URL/file filters, timeout, saveAs completed download metadata and final path when CDP supplies it
cookie.get / cookie.set / cookie.delete cookie name/value and optional URL/domain/path scope browser cookie data or mutation status; set defaults to the active page URL and / path
storage.get / storage.set / storage.remove / storage.clear `scope: local session, key/value and optional saveAs`
set values applied values
data.emit literal channel, optional key, arbitrary value, optional saveAs/sensitive DataEmitted intermediate result plus normal step output
hook hook, optional event hook result in state
workflow.call literal workflowId, positive integer version, inputs, optional saveAs; browser must be inherit pinned child identity and declared outputs
branch JavaScript expression using ctx true or false port
condition mode, declarative conditions, optional negate/saveAs true or false port and safe match metadata
evaluate JavaScript expression using ctx, optional saveAs/timeout evaluated value
screenshot optional path, fullPage absolute artifact path
webhook url, body, headers, timeout HTTP status
http.request URL, method, headers/body, expected statuses, bounds, optional saveAs status, headers, decoded JSON or text body, final URL

Extract properties in v0.1 are innerText, textContent, value, href, html, and attribute:name.

Finding an existing page and opening a fallback are two explicit actions. A missing page is a normal decision, not a generic failure:

nodes:
  select_page:
    type: tab.switch
    with: {urlContains: yuanbao.tencent.com, titleContains: 元宝}
  open_page:
    type: tab.open
    with: {url: "${inputs.pageUrl}"}
  continue_flow: {type: wait, with: {duration: 500ms}}
edges:
  - {from: select_page, port: found, to: continue_flow}
  - {from: select_page, port: missing, to: open_page}
  - {from: open_page, port: success, to: continue_flow}

tab.switch never turns ambiguous matches or CDP errors into missing, so the fallback cannot silently open a duplicate page. When it is the first operation, Core uses a disposable blank target only to establish the attached CDP session; the blank target is closed after either branch activates the real page.

Downloads use an explicit three-step sequence: download.prepare, the action that triggers the download, then download.wait. Preparing first is required because CDP download events cannot be reconstructed after they have passed. http.request runs in Core and intentionally does not copy cookies from the attached browser; pass an explicit header only when the workflow requires one.

fill.with.value keeps accepting ordinary strings and FlowContext templates. Studio can also persist an explicit source so authors do not need to hand-write templates or add a separate extraction node:

# A declared workflow input.
value: { source: input, input: comment }

# An upstream extraction, calculation, listener, or model result.
value: { source: context, path: vars.modelResult }

# Read another element immediately before filling the target.
value:
  source: element
  locator: { css: "#source-field" }
  property: value

Text sources may still compose multiple bindings, for example 你好,${ctx.inputs.name}:${ctx.vars.modelResult}. Numbers and booleans are formatted as text; objects and arrays are encoded as JSON. Resolved fill content is not copied into node output or runtime events.

Form and keyboard actions

All three actions use a current element locator; none accepts or persists coordinates. See Form actions for the exact contracts, failure cases, recording behavior, and CDP security boundary.

Interrupt guards

spec.guards handles unexpected page state without adding fake branches to the main graph. A detector observes the page in parallel, but it is read-only. When matched, the scheduler pauses at a node safe point, gives the guard handler exclusive browser-action ownership, and resumes the main graph afterward.

guards:
  - id: verification
    name: Verification dialog
    priority: 100
    trigger:
      type: dom
      locator: { css: ".verification-dialog" }
      operator: exists
      debounce: 300ms
      pollInterval: 300ms
    policy:
      cooldown: 3s
      maxActivations: 3
      timeout: 2m
      resolveTimeout: 5s
      resume: retryInterruptedNode
      onFailure: failRun
    flow:
      nodes:
        start: { type: start }
        wait_manual:
          type: wait
          with:
            locator: { css: ".verification-dialog" }
            state: hidden
            timeout: 90s
        done: { type: end }
      edges:
        - { from: start, port: success, to: wait_manual }
        - { from: wait_manual, port: success, to: done }

The handler graph has an isolated step namespace. During an activation, templates and hooks can read ctx.interrupt.id, interruptedNode, activationCount, detectedAt, and steps. A guard never interrupts another guard. Handler completion alone is not success: Core polls the original trigger for up to resolveTimeout and emits GuardFailed when it still matches. Cooldown, activation limits, and handler timeout bound repeated dialogs.

The resume policy is an explicit continuation choice:

  • retryInterruptedNode retries an action that failed behind an overlay.
  • continue resumes after the suspended node.
  • restartWorkflow is for login/session handlers that refresh the whole document. It keeps inputs, the attached browser, network listeners, guard counters, and the same run, but clears main-graph vars, steps, and outputs and resumes at the workflow start node. Lifecycle attachment is not repeated.

Because restartWorkflow can repeat earlier side effects, enable it only when the business page refresh invalidates all prior page-derived state and keep maxActivations bounded.

Published workflow calls

workflow.call reuses an exact immutable published workflow version in the current browser session:

invoke_login:
  type: workflow.call
  with:
    workflowId: shared-login
    version: 7
    browser: inherit
    inputs:
      tenant: "${inputs.tenant}"
      username: "${inputs.username}"
    saveAs: login

workflowId and version are literal publication references, not templates. Input values are resolved in the parent frame and copied into a fresh child frame. Child inputs, vars, and steps are isolated; only outputs declared by the child workflow return to the parent. The node output is {workflowId, version, outputs} and saveAs, when present, stores only the declared outputs object under parent vars.

The child inherits the already connected browser. It never starts or closes a browser, and child browser launch/attach fields cannot replace the root session. browser: new is deliberately unsupported. Exact-reference recursion is rejected and call depth is limited to 16. A child failure fails the parent call node and therefore participates in its normal retry/failure policy.

Studio publish and run preparation resolve calls only from immutable published versions; a draft or implicit latest version is never substituted. A standalone Core runner must provide a published-workflow resolver or the call fails explicitly. In v1alpha1 a child call is an opaque debug/event frame: parent breakpoints do not match child node IDs and ordinary child node events are not mixed into the parent graph stream.

Discovery marks this node with executionScopes: [saved]. MCP ad-hoc source tools workflow_validate and workflow_run intentionally reject it before browser startup because they do not own a workflow store/resolver; publish the parent and use workflow_run_saved. Saved Studio/API execution remains fully supported.

Hooks

hooks:
  normalize:
    runtime: javascript
    entry: handle
    timeout: 500ms
    source: |
      function handle(ctx) {
        return {
          patch: {answer: ctx.event.response.body},
          output: {received: true},
          emit: [{type: "AnswerReceived", data: {ok: true}}],
          control: {action: "continue"}
        };
      }

Supported lifecycle references include workflow beforeRun, afterRun, onRunSuccess, onRunFailure, and node beforeNode, beforeLocate, afterLocate, beforeAction, afterAction, onSuccess, onFailure, onRetry.

Parallel network listeners

spec.listeners are workflow-level resources, not graph nodes. Core subscribes all of them after the browser connects and before the first node starts. Each listener receives completed HTTP exchanges independently while the main graph continues executing:

hooks:
  normalizeReply:
    runtime: javascript
    entry: handle
    source: |
      function handle(ctx) {
        const body = JSON.parse(ctx.event.response.body || '{}');
        return {patch: {chat: {lastReply: body.answer}}, emit: [{type: 'ReplyCaptured'}]};
      }
  reportListenerError:
    runtime: javascript
    entry: handle
    source: |
      function handle(ctx) { return {emit: [{type: 'ReplyCaptureFailed', data: {message: ctx.event.error}}]}; }
listeners:
  - id: chat-replies
    match: {url: "**/api/chat/**", method: POST, status: 200, kind: json}
    capture: {responseBody: true, maxBytes: 1048576}
    saveAs: network.lastChat
    onMatch: [normalizeReply]
    onError: [reportListenerError]
    maxMatches: 20

At least one match field is required and listener IDs must be unique. maxMatches: 0 means unlimited within the current run. Capture is opt-in; maxBytes defaults to 1 MiB and is capped at 10 MiB. saveAs stores the last captured exchange. onMatch and onError may patch vars, publish hook output, and emit custom events. They cannot redirect the graph token because listener callbacks execute concurrently outside graph control flow. Runtime emits NetworkMatched, ListenerSucceeded, and ListenerFailed events with the listener ID for observability.

Prefer saveAs without onMatch or onError: the captured exchange is already available to later graph nodes. A listener Hook is only needed for a genuine per-match transform or emitted event. Core parses every Hook before execution; its entry must name a top-level function in source (entry omitted means handle). A missing entry is a compile error, so it cannot become a repeated ListenerFailed event during an SSE stream.

Network matching exposes business response kinds only: json, xhr, and sse. sse covers native EventSource and Fetch/XHR text/event-stream responses and matches actual cumulative stream data, not connection headers. CDP resource types and request lifecycle phases are deliberately not part of the DSL. If tab.open or tab.switch changes the active tab, the workflow's parallel listeners move to that tab as part of the same run.

Failure policy

failure:
  timeout: 15s
  retry:
    maxAttempts: 3
    backoff: exponential
    baseDelay: 400ms
  then: goto
  target: recover

then supports goto, continue, or the default abort behavior.

Inline data evaluation

Use evaluate for bounded, side-effect-free data computation without registering a reusable hook:

normalize:
  type: evaluate
  with:
    expression: "ctx.inputs.items.map(item => ({ id: item.id, name: String(item.name).trim() }))"
    saveAs: normalizedItems
    timeout: 250ms

The expression runs in the same isolated Goja runtime as hooks: it has no browser, filesystem, or network host APIs. Its ctx snapshot contains inputs, vars, steps, run, and the current node. The result is emitted as steps.<nodeId>.value; when saveAs is present it is also written under vars.

Use assert when a business invariant should participate in the normal node failure policy instead of becoming data:

check_result:
  type: assert
  with:
    expression: "ctx.vars.result != null && ctx.vars.result.status === 'ok'"
    message: result API did not return an ok payload
    timeout: 500ms
  failure:
    then: continue

The expression must return a strict boolean. false, a timeout, or an exception fails the node and can retry, abort, jump, or follow its failure port. The message passes through the same secret-redaction boundary as other execution errors.

Multi-way routing

switch evaluates cases in declaration order and follows the first expression that returns true. If none matches it follows default (or the port named by with.default). Case/default port names match [A-Za-z_][A-Za-z0-9_-]{0,31} and a switch supports at most 32 cases.

route:
  type: switch
  with:
    cases:
      - {port: paid, expression: "ctx.vars.account.plan === 'paid'"}
      - {port: trial, expression: "ctx.vars.account.trial === true"}
    default: guest
  # edges must declare paid, trial, and guest exactly once

Expressions run in the isolated Goja runtime. A non-boolean case result fails the node instead of applying JavaScript truthiness, which keeps routing configuration explicit and testable.

Structured foreach

foreach is a bounded, sequential structured loop. The graph remains acyclic: the body edge enters the body and a loop.end terminal closes it; done continues after all items. There is no graph edge back to the foreach node.

eachComment:
  type: foreach
  with:
    items: "ctx.inputs.comments"       # must return an array
    item: loop.comment                 # defaults to vars.item
    index: loop.index                  # defaults to vars.index
    maxIterations: 100                 # defaults to 1000; hard max 10000
    collect: "ctx.vars.savedComment"   # evaluated after a completed body
    saveAs: savedComments              # collected array destination

bodyDone:
  type: loop.end
  with: {loop: eachComment}

edges:
  - {from: eachComment, port: body, to: firstBodyNode}
  - {from: lastBodyNode, port: success, to: bodyDone}
  - {from: eachComment, port: done, to: afterLoop}

item and index are scoped variables: previous values are restored when the loop succeeds or fails. collect is evaluated once after every body that reaches loop.end, appended in input order, and stored at saveAs. An empty input stores an empty array and immediately follows done.

saveAs must not equal, contain, or be contained by the item/index paths; otherwise restoring the iteration aliases could erase the collected result. The body is owned by its structured loop: no root or sibling edge may enter a body node, and one body subgraph cannot be shared by multiple foreach/for nodes.

Conditional flow inside a body may route to loop.continue or loop.break; both require the with.loop field naming their foreach or for owner and have no outgoing edge. Neither control terminal evaluates collect for that iteration. Nested foreach is supported to depth four.

The entire foreach node cannot use failure.retry.maxAttempts > 1: replaying completed browser actions is unsafe without durable per-iteration checkpoints and idempotency. Individual nodes inside the body retain their normal retry policy. Runtime outputs under steps.<foreachId> include total, iterations (attempted), completed, and collected. Iteration lifecycle events are emitted for real-time UI/API progress.

Numeric for

for uses the same structured body/done graph, scoped item/index, collect/saveAs, nesting, break, and continue semantics as foreach. Its range is computed before the body starts, so an oversized range cannot partially execute browser actions.

pages:
  type: for
  with:
    from: "ctx.inputs.firstPage"  # finite number or Goja expression
    to: "ctx.inputs.lastPage"
    step: 1                      # defaults to 1; must not be zero
    inclusive: true              # defaults to false
    item: page.number            # defaults to vars.item
    index: page.index            # zero-based, defaults to vars.index
    maxIterations: 100
    collect: "ctx.vars.pageResult"
    saveAs: pageResults

pageDone:
  type: loop.end
  with: {loop: pages}            # owner may be foreach or for

edges:
  - {from: pages, port: body, to: openPage}
  - {from: lastPageBodyNode, port: success, to: pageDone}
  - {from: pages, port: done, to: afterPages}

Positive and negative steps are supported. A direction/boundary mismatch yields an empty sequence: for example from: 5, to: 0, step: 1 performs zero iterations. The default range is end-exclusive; inclusive: true includes to only when the step lands on it. Bounds and a dynamic step are evaluated in the isolated Goja runtime and must produce finite numbers. A dynamic zero step fails before the body starts. Numeric loops emit ForIteration* progress events with only index/count metadata; item values are intentionally omitted to prevent secret data from leaking through event streams.

Bounded while and until

while is a pre-test loop: it evaluates its strict-boolean Goja condition before every body entry and may execute zero times. until is a post-test (do-until) loop: it executes the body first and evaluates condition after normal completion or continue, so it always executes at least once unless the run is already canceled.

repeat:
  type: while                 # or until
  with:
    condition: "ctx.vars.pending === true"
    maxIterations: 100        # default 1000; hard maximum 10000
    timeout: 500ms            # default 500ms; allowed 1ms..30s
    iteration: loop.iteration # optional counter alias
    index: loop.index         # defaults to vars.index
    collect: "ctx.vars.result"
    saveAs: results

repeatDone:
  type: loop.end
  with: {loop: repeat}

edges:
  - {from: repeat, port: body, to: firstBodyNode}
  - {from: lastBodyNode, port: success, to: repeatDone}
  - {from: repeat, port: done, to: afterRepeat}

The condition result must be an actual JavaScript boolean; truthy/falsy coercion is never applied. The iteration budget counts body entries. A while whose condition remains true and an until whose condition remains false fail before body entry maxIterations + 1. Condition errors, timeout, or cancellation never replay an already completed body.

The default scoped counter is only vars.index, starting at zero. Configure iteration to expose the same ordinal at another path. Both counters restore their prior values after success or failure.

loop.continue skips collection. It returns while to the next pre-test and sends until to the current iteration's post-test. loop.break skips collection and any later condition, then follows done. Whole-loop retries above one attempt are invalid because body actions may have side effects.

loop.end with with.loop is the only normal terminal contract for all structured loop types. Mixed foreach, for, while, and until nesting is supported to depth four. Removed legacy terminal names and owner fields are rejected during validation instead of silently normalized.

Condition-loop events contain only loop type, node ID, index, limit, and terminal metadata. They never contain condition source, item values, vars, collected values, or raw JavaScript errors.

Page extraction and model computation

page.extract reads bounded text, textContent, or html from document.body or an optional Locator and writes it to saveAs. Extracted content is visible by default. Set sensitive: true explicitly only when the raw value must remain usable inside the executor but hidden from public snapshots and events.

extract.list queries repeated elements under the document or an optional unique root Locator. Its literal itemSelector matches each business item; named fields use an optional relative CSS selector and read innerText, textContent, value, href, html, or attribute:<name>. Results are bounded by item count and per-field Unicode characters. Missing children become null and absolute coordinates are never stored. Structured business fields are visible by default so FlowContext can be inspected. Set sensitive: true explicitly for messages, comments, or private records; this masks every extracted scalar at public boundaries while keeping the typed array available to foreach and model.invoke.

model.invoke resolves a literal connection through the control plane, sends templated instructions plus arbitrary JSON-compatible input, and optionally writes text or a parsed JSON value to saveAs. operation describes generate, classify, extract, transform, evaluate, or custom computation without adding hidden prompts. output.type=json accepts a provider-neutral schema and fails before downstream effects if the output is not valid JSON. A workflow never carries an endpoint credential. Discovery marks the node saved-only because Studio owns its model-connection store. The optional model, temperature, and maxOutputTokens fields override connection defaults.

Use condition to combine typed values with mode=all|any and route through true/false without JavaScript. Exact ${inputs.*}, ${vars.*}, and ${steps.*} bindings preserve JSON types and can feed action parameters, loops, subflows, webhooks, or predicates. See Typed value flow and Page content and model nodes for the full contracts.