Files
lda-wf/docs/historical/superpowers/plans/2026-06-15-browser-click-workflow-example.md
T

25 KiB

Browser Click Workflow Example Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a deterministic multi-node workflow example that opens a local browser page with a button, captures before/after snapshots around a simulated or human click, and runs through artifact/deployment/run.

Architecture: The example is a trusted Python source under examples/browser_click_workflow/. It uses a tiny stdlib HTTP server as the page fixture and three serial workflow nodes: open_click_page -> wait_for_click -> collect_snapshots. Tests run with simulate=true and open_browser=false; manual users can set open_browser=true.

Tech Stack: Python 3.14, wf_authoring.node, wf_config, wf_server.config, stdlib http.server, threading, urllib.request, pytest.


Boundaries

  • Do not implement fork/gather.
  • Do not depend on Playwright or Playwright MCP for this committed baseline.
  • Do not output base64 screenshots. Snapshots are bounded JSON values: title, status text, button text, clicked flag, event log.
  • Any background HTTP server started by the source must be shut down by the final workflow node or explicit helper cleanup.
  • The later agent-trial harness may use npx -y @playwright/mcp@latest; this example must remain deterministic without it.

File Structure

  • Create examples/browser_click_workflow/ops.py — Python source with the three @node capabilities and stdlib local page session manager.
  • Create examples/browser_click_workflow/wf.config.json — local workflow config with local.browser_click Python source and RPC transport on an unused example port.
  • Create examples/browser_click_workflow/run-input.json — deterministic run input using simulated click and no browser popup.
  • Create examples/browser_click_workflow/README.md — runbook and expected output.
  • Create tests/examples/test_browser_click_workflow_example.py — helper tests and full artifact/deployment/run lifecycle test.
  • Modify docs/add/system-design-implementation.md — reference this as optional richer multi-node evidence, without replacing the deterministic report case study.
  • Modify docs/add/evidence-index.md — add browser click example as supplemental lifecycle/graph evidence.
  • Modify docs/project_map.md — list the new example.
  • Modify docs/current_roadmap.md — mark browser-click example completed.

Task 1: Add Browser Click Python Source

Files:

  • Create: examples/browser_click_workflow/ops.py

  • Test: tests/examples/test_browser_click_workflow_example.py

  • Step 1: Create failing helper tests

Create tests/examples/test_browser_click_workflow_example.py with:

from __future__ import annotations

import pytest

from examples.browser_click_workflow.ops import (
    CollectSnapshotsInput,
    OpenPageInput,
    WaitForClickInput,
    _active_session_count,
    _collect_snapshots,
    _open_click_page,
    _wait_for_click,
)


def test_browser_click_source_simulates_click_and_cleans_up() -> None:
    opened = _open_click_page(
        OpenPageInput(button_label="Launch Workflow", open_browser=False)
    )

    assert opened.before.clicked is False
    assert opened.before.button_text == "Launch Workflow"
    assert opened.before.status_text == "Waiting for click"

    clicked = _wait_for_click(
        WaitForClickInput(session_id=opened.session_id, simulate=True, timeout_seconds=2)
    )
    result = _collect_snapshots(
        CollectSnapshotsInput(
            session_id=opened.session_id,
            before=opened.before,
            after=clicked.after,
        )
    )

    assert result.before.clicked is False
    assert result.after.clicked is True
    assert result.after.status_text == "Button clicked"
    assert result.closed is True
    assert _active_session_count() == 0


def test_browser_click_source_human_timeout_cleans_up() -> None:
    opened = _open_click_page(OpenPageInput(open_browser=False))

    with pytest.raises(TimeoutError, match="timed out waiting for click"):
        _wait_for_click(
            WaitForClickInput(
                session_id=opened.session_id,
                simulate=False,
                timeout_seconds=0.01,
            )
        )

    _collect_snapshots(
        CollectSnapshotsInput(
            session_id=opened.session_id,
            before=opened.before,
            after=opened.before,
        )
    )
    assert _active_session_count() == 0
  • Step 2: Run tests to verify failure

Run:

uv run pytest tests/examples/test_browser_click_workflow_example.py -q

Expected: fail because examples.browser_click_workflow.ops does not exist.

  • Step 3: Implement ops.py

Create examples/browser_click_workflow/ops.py:

from __future__ import annotations

import json
import threading
import uuid
import webbrowser
from dataclasses import dataclass
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import ClassVar
from urllib.request import Request, urlopen

from pydantic import BaseModel, Field

from wf_authoring import node


class Snapshot(BaseModel):
    title: str
    url: str
    button_text: str
    status_text: str
    clicked: bool
    events: list[str] = Field(default_factory=list, max_length=10)


class OpenPageInput(BaseModel):
    button_label: str = "Click to continue"
    open_browser: bool = Field(
        default=False,
        description="Open the page in the default browser for manual runs.",
    )


class OpenPageOutput(BaseModel):
    session_id: str
    url: str
    before: Snapshot


class WaitForClickInput(BaseModel):
    session_id: str
    simulate: bool = Field(
        default=True,
        description="When true, perform a deterministic HTTP click instead of waiting.",
    )
    timeout_seconds: float = Field(default=10.0, gt=0)


class WaitForClickOutput(BaseModel):
    clicked: bool
    after: Snapshot


class CollectSnapshotsInput(BaseModel):
    session_id: str
    before: Snapshot
    after: Snapshot


class CollectSnapshotsOutput(BaseModel):
    before: Snapshot
    after: Snapshot
    closed: bool


@dataclass(slots=True)
class _ClickSession:
    session_id: str
    button_label: str
    server: ThreadingHTTPServer
    thread: threading.Thread
    clicked: threading.Event
    events: list[str]

    @property
    def url(self) -> str:
        host, port = self.server.server_address
        return f"http://{host}:{port}/"

    def snapshot(self) -> Snapshot:
        clicked = self.clicked.is_set()
        return Snapshot(
            title="Workflow Click Fixture",
            url=self.url,
            button_text=self.button_label,
            status_text="Button clicked" if clicked else "Waiting for click",
            clicked=clicked,
            events=list(self.events[-10:]),
        )

    def close(self) -> None:
        self.server.shutdown()
        self.server.server_close()
        self.thread.join(timeout=2)


_SESSIONS: dict[str, _ClickSession] = {}
_SESSIONS_LOCK = threading.Lock()


class _ClickHandler(BaseHTTPRequestHandler):
    server: _ClickServer

    def log_message(self, _format: str, *_args: object) -> None:
        """Keep example runs quiet; workflow trace carries the useful output."""

    def do_GET(self) -> None:
        if self.path == "/":
            self._send_html()
            return
        if self.path == "/snapshot":
            self._send_json(self.server.session.snapshot().model_dump(mode="json"))
            return
        self.send_error(HTTPStatus.NOT_FOUND)

    def do_POST(self) -> None:
        if self.path != "/click":
            self.send_error(HTTPStatus.NOT_FOUND)
            return
        session = self.server.session
        session.clicked.set()
        session.events.append("click")
        self._send_json(session.snapshot().model_dump(mode="json"))

    def _send_html(self) -> None:
        session = self.server.session
        status = "Button clicked" if session.clicked.is_set() else "Waiting for click"
        html = f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Workflow Click Fixture</title>
  <style>
    body {{ font-family: Georgia, serif; margin: 3rem; background: #f8f3e8; color: #1f2933; }}
    main {{ max-width: 720px; padding: 2rem; border: 3px solid #1f2933; background: #fffaf0; }}
    button {{ font-size: 1.25rem; padding: 0.9rem 1.3rem; border: 2px solid #1f2933; background: #f2b84b; cursor: pointer; }}
    #status {{ margin-top: 1rem; font-weight: 700; }}
  </style>
</head>
<body>
  <main>
    <h1>Workflow Click Fixture</h1>
    <button id="continue" type="button">{session.button_label}</button>
    <p id="status">{status}</p>
  </main>
  <script>
    document.getElementById("continue").addEventListener("click", async () => {{
      await fetch("/click", {{ method: "POST" }});
      document.getElementById("status").textContent = "Button clicked";
    }});
  </script>
</body>
</html>"""
        encoded = html.encode("utf-8")
        self.send_response(HTTPStatus.OK)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(encoded)))
        self.end_headers()
        self.wfile.write(encoded)

    def _send_json(self, payload: dict[str, object]) -> None:
        encoded = json.dumps(payload).encode("utf-8")
        self.send_response(HTTPStatus.OK)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(encoded)))
        self.end_headers()
        self.wfile.write(encoded)


class _ClickServer(ThreadingHTTPServer):
    session: _ClickSession
    allow_reuse_address: ClassVar[bool] = True


@node(name="open_click_page")
def open_click_page(payload: OpenPageInput) -> OpenPageOutput:
    return _open_click_page(payload)


@node(name="wait_for_click")
def wait_for_click(payload: WaitForClickInput) -> WaitForClickOutput:
    return _wait_for_click(payload)


@node(name="collect_snapshots")
def collect_snapshots(payload: CollectSnapshotsInput) -> CollectSnapshotsOutput:
    return _collect_snapshots(payload)


def _open_click_page(payload: OpenPageInput) -> OpenPageOutput:
    session_id = f"click_{uuid.uuid4().hex}"
    server = _ClickServer(("127.0.0.1", 0), _ClickHandler)
    session = _ClickSession(
        session_id=session_id,
        button_label=payload.button_label,
        server=server,
        thread=threading.Thread(target=server.serve_forever, daemon=True),
        clicked=threading.Event(),
        events=["opened"],
    )
    server.session = session
    with _SESSIONS_LOCK:
        _SESSIONS[session_id] = session
    session.thread.start()
    if payload.open_browser:
        webbrowser.open(session.url)
    return OpenPageOutput(
        session_id=session_id,
        url=session.url,
        before=session.snapshot(),
    )


def _wait_for_click(payload: WaitForClickInput) -> WaitForClickOutput:
    session = _get_session(payload.session_id)
    if payload.simulate:
        request = Request(f"{session.url}click", method="POST")
        with urlopen(request, timeout=payload.timeout_seconds) as response:
            response.read()
    elif not session.clicked.wait(timeout=payload.timeout_seconds):
        raise TimeoutError("timed out waiting for click")
    return WaitForClickOutput(clicked=session.clicked.is_set(), after=session.snapshot())


def _collect_snapshots(payload: CollectSnapshotsInput) -> CollectSnapshotsOutput:
    session = _get_session(payload.session_id)
    session.close()
    with _SESSIONS_LOCK:
        _SESSIONS.pop(payload.session_id, None)
    return CollectSnapshotsOutput(before=payload.before, after=payload.after, closed=True)


def _get_session(session_id: str) -> _ClickSession:
    with _SESSIONS_LOCK:
        session = _SESSIONS.get(session_id)
    if session is None:
        raise ValueError(f"unknown click session {session_id!r}")
    return session


def _active_session_count() -> int:
    with _SESSIONS_LOCK:
        return len(_SESSIONS)


registry = [open_click_page, wait_for_click, collect_snapshots]
  • Step 4: Run helper tests

Run:

uv run pytest tests/examples/test_browser_click_workflow_example.py::test_browser_click_source_simulates_click_and_cleans_up tests/examples/test_browser_click_workflow_example.py::test_browser_click_source_human_timeout_cleans_up -q

Expected: both tests pass.

  • Step 5: Commit
git add examples/browser_click_workflow/ops.py tests/examples/test_browser_click_workflow_example.py
git commit -m "feat: add browser click source example"

Task 2: Add Config, Input, And Multi-Node Lifecycle Test

Files:

  • Create: examples/browser_click_workflow/wf.config.json

  • Create: examples/browser_click_workflow/run-input.json

  • Modify: tests/examples/test_browser_click_workflow_example.py

  • Step 1: Create config

Create examples/browser_click_workflow/wf.config.json:

{
  "version": 1,
  "client": {
    "target": {
      "kind": "rpc_http",
      "url": "http://127.0.0.1:8772/rpc",
      "timeout_seconds": 30
    }
  },
  "server": {
    "store": {
      "kind": "filesystem",
      "root": ".wf_browser_click_store"
    },
    "transports": [
      {
        "kind": "rpc_http",
        "host": "127.0.0.1",
        "port": 8772,
        "path": "/rpc"
      }
    ],
    "sources": [
      {
        "kind": "python",
        "id": "local.browser_click",
        "path": ".",
        "module": "ops",
        "registry": "registry"
      }
    ]
  }
}
  • Step 2: Create deterministic run input

Create examples/browser_click_workflow/run-input.json:

{
  "button_label": "Launch Workflow",
  "open_browser": false,
  "simulate": true,
  "timeout_seconds": 2
}
  • Step 3: Add full lifecycle test

Append to tests/examples/test_browser_click_workflow_example.py:

from pathlib import Path

from wf_config import load_workflow_config
from wf_server.config import build_workflow_server_from_workflow_config


EXAMPLE_DIR = Path(__file__).resolve().parents[2] / "examples" / "browser_click_workflow"


async def test_browser_click_workflow_artifact_deployment_run_path(tmp_path) -> None:
    config = load_workflow_config(EXAMPLE_DIR / "wf.config.json")
    config.server.store.root = tmp_path / "store"
    server = build_workflow_server_from_workflow_config(config)

    plan = {
        "name": "browser_click_case_study",
        "input_schema": {
            "type": "object",
            "properties": {
                "button_label": {"type": "string"},
                "open_browser": {"type": "boolean"},
                "simulate": {"type": "boolean"},
                "timeout_seconds": {"type": "number"},
            },
            "required": ["button_label", "open_browser", "simulate", "timeout_seconds"],
        },
        "state_schema": {
            "type": "object",
            "properties": {
                "opened": {"type": "object", "reducer": "wf.std.replace"},
                "clicked": {"type": "object", "reducer": "wf.std.replace"},
                "result": {"type": "object", "reducer": "wf.std.replace"},
            },
        },
        "output_schema": {
            "type": "object",
            "properties": {
                "before": {"type": "object"},
                "after": {"type": "object"},
                "closed": {"type": "boolean"},
            },
            "required": ["before", "after", "closed"],
        },
        "outcomes": ["ok"],
        "start": "open",
        "nodes": [
            {
                "id": "open",
                "type": "node",
                "node": "local.browser_click.open_click_page",
                "input": [
                    {
                        "path": {"root": "input", "parts": ["button_label"]},
                        "target": {"root": "local", "parts": ["button_label"]},
                    },
                    {
                        "path": {"root": "input", "parts": ["open_browser"]},
                        "target": {"root": "local", "parts": ["open_browser"]},
                    },
                ],
                "output": [
                    {
                        "source": {"root": "local", "parts": []},
                        "target": {"root": "state", "parts": ["opened"]},
                    }
                ],
            },
            {
                "id": "wait",
                "type": "node",
                "node": "local.browser_click.wait_for_click",
                "input": [
                    {
                        "path": {"root": "state", "parts": ["opened", "session_id"]},
                        "target": {"root": "local", "parts": ["session_id"]},
                    },
                    {
                        "path": {"root": "input", "parts": ["simulate"]},
                        "target": {"root": "local", "parts": ["simulate"]},
                    },
                    {
                        "path": {"root": "input", "parts": ["timeout_seconds"]},
                        "target": {"root": "local", "parts": ["timeout_seconds"]},
                    },
                ],
                "output": [
                    {
                        "source": {"root": "local", "parts": []},
                        "target": {"root": "state", "parts": ["clicked"]},
                    }
                ],
            },
            {
                "id": "collect",
                "type": "node",
                "node": "local.browser_click.collect_snapshots",
                "input": [
                    {
                        "path": {"root": "state", "parts": ["opened", "session_id"]},
                        "target": {"root": "local", "parts": ["session_id"]},
                    },
                    {
                        "path": {"root": "state", "parts": ["opened", "before"]},
                        "target": {"root": "local", "parts": ["before"]},
                    },
                    {
                        "path": {"root": "state", "parts": ["clicked", "after"]},
                        "target": {"root": "local", "parts": ["after"]},
                    },
                ],
                "output": [
                    {
                        "source": {"root": "local", "parts": []},
                        "target": {"root": "state", "parts": ["result"]},
                    }
                ],
            },
        ],
        "edges": [
            {"from": "open", "outcome": "ok", "to": "wait"},
            {"from": "wait", "outcome": "ok", "to": "collect"},
            {"from": "collect", "outcome": "ok", "to": "__end__"},
        ],
        "output": [
            {
                "path": {"root": "state", "parts": ["result", "before"]},
                "target": {"root": "local", "parts": ["before"]},
            },
            {
                "path": {"root": "state", "parts": ["result", "after"]},
                "target": {"root": "local", "parts": ["after"]},
            },
            {
                "path": {"root": "state", "parts": ["result", "closed"]},
                "target": {"root": "local", "parts": ["closed"]},
            },
        ],
    }

    await server.api.create_artifact_from_plan(
        artifact_id="browser_click_case_study",
        version=1,
        title="Browser Click Case Study",
        plan=plan,
        outcomes=["ok"],
        source_bindings={"local.browser_click": "local.browser_click"},
    )
    await server.api.save_deployment(
        {
            "id": "browser_click_case_study.default",
            "artifact_id": "browser_click_case_study",
            "artifact_version": 1,
            "bindings": {"local.browser_click": "local.browser_click"},
        }
    )
    run = await server.api.run_deployment(
        deployment_id="browser_click_case_study.default",
        workflow_input={
            "button_label": "Launch Workflow",
            "open_browser": False,
            "simulate": True,
            "timeout_seconds": 2,
        },
    )

    assert run["status"] == "completed"
    assert run["output"]["before"]["clicked"] is False
    assert run["output"]["after"]["clicked"] is True
    assert run["output"]["after"]["status_text"] == "Button clicked"
    assert run["output"]["closed"] is True
    assert run["trace_count"] >= 3
  • Step 4: Run lifecycle test

Run:

uv run pytest tests/examples/test_browser_click_workflow_example.py -q

Expected: all browser-click tests pass.

  • Step 5: Validate config

Run:

uv run wf config validate examples/browser_click_workflow/wf.config.json

Expected: valid config output.

  • Step 6: Commit
git add examples/browser_click_workflow/wf.config.json examples/browser_click_workflow/run-input.json tests/examples/test_browser_click_workflow_example.py
git commit -m "test: prove browser click workflow lifecycle"

Files:

  • Create: examples/browser_click_workflow/README.md

  • Modify: docs/add/system-design-implementation.md

  • Modify: docs/add/evidence-index.md

  • Modify: docs/project_map.md

  • Modify: docs/current_roadmap.md

  • Step 1: Create README

Create examples/browser_click_workflow/README.md:

# Browser Click Workflow Example

This example demonstrates a serial multi-node workflow over a trusted Python
source:

```text
open_click_page -> wait_for_click -> collect_snapshots
```

It opens a local HTML page with one visible button, captures a bounded JSON
snapshot before the click, performs a deterministic simulated click by default,
captures an after snapshot, closes the local server, and returns both snapshots
as workflow output.

The example deliberately avoids browser screenshots and base64 payloads. The
snapshots are small JSON objects suitable for CLI and LLM-agent output.

## Run

From the repository root:

```powershell
uv run wf config validate examples/browser_click_workflow/wf.config.json
uv run wf-rpc-server --config examples/browser_click_workflow/wf.config.json
```

In another terminal:

```powershell
uv run wf --config examples/browser_click_workflow/wf.config.json status
uv run wf --config examples/browser_click_workflow/wf.config.json cap list --source local.browser_click
```

The full artifact/deployment/run lifecycle is covered by:

```powershell
uv run pytest tests/examples/test_browser_click_workflow_example.py -q
```

## Manual Browser Mode

The checked-in test uses `"simulate": true` and `"open_browser": false`.
For manual experimentation, set `"open_browser": true` in the run input and
use `"simulate": false`; then click the button before `timeout_seconds` elapses.
Any browser or local server opened by the source is closed by the final
`collect_snapshots` node.
  • Step 2: Link docs

Patch docs:

  • In docs/project_map.md, add examples/browser_click_workflow/ to the examples list as the serial browser-click workflow example.
  • In docs/add/evidence-index.md, add a supplemental evidence bullet under Python/source or workflow lifecycle evidence:
- `examples/browser_click_workflow/`
- `tests/examples/test_browser_click_workflow_example.py`
  • In docs/add/system-design-implementation.md, add one sentence after the report workflow case-study scope paragraph:
A supplemental browser-click example demonstrates a serial three-node workflow
with bounded before/after snapshots; it is supporting evidence, not the main
case study.
  • In docs/current_roadmap.md, add a short completed milestone:
- Completed supplemental browser-click workflow example with serial multi-node lifecycle evidence.
  • Step 3: Run docs checks

Run:

uv run pytest tests/docs -q

Expected: pass.

  • Step 4: Run focused verification

Run:

uv run pytest tests/examples/test_browser_click_workflow_example.py tests/docs -q
uv run ruff check examples/browser_click_workflow tests/examples/test_browser_click_workflow_example.py
uv run basedpyright --level error examples/browser_click_workflow tests/examples/test_browser_click_workflow_example.py
git diff --check

Expected:

  • Tests pass.

  • Ruff has no errors.

  • Basedpyright has no errors.

  • git diff --check reports no whitespace errors except acceptable CRLF warnings on Windows.

  • Step 5: Commit

git add examples/browser_click_workflow docs/add/system-design-implementation.md docs/add/evidence-index.md docs/project_map.md docs/current_roadmap.md tests/examples/test_browser_click_workflow_example.py
git commit -m "docs: add browser click workflow example"

Self-Review Notes

  • The example is serial multi-node, not fork/gather.
  • The source closes its server in collect_snapshots.
  • The test uses simulated click and does not open the user's browser.
  • The README warns about manual browser mode and cleanup.
  • Playwright MCP is intentionally excluded from this baseline; it belongs in a later agent-trial harness plan.