@bffless/workflow-headless
v1.7.0
Published
Playwright driver that runs a BFFless Workflow harness workflow unattended (spec apps/workflow/docs/spec/07-headless.md)
Maintainers
Readme
@bffless/workflow-headless
The Playwright driver for the BFFless Workflow harness: it runs one workflow unattended — CI, a schedule, a batch — by driving the real harness page in headless Chromium.
Nothing of the harness is re-implemented here. Unattended and interactive runs
are the same code, the same rows and the same history; this package only opens
the page, follows it, and writes down what happened. The page contract it
depends on is 07-headless.md.
Install
pnpm add -D @bffless/workflow-headless
pnpm exec playwright install chromiumUse
workflow-headless run https://workflow.example.com hello/interactive \
--inputs inputs.json --out ./run-artifacts --timeout 30m
workflow-headless runs https://workflow.example.com hello/interactive --last 10
workflow-headless resume https://workflow.example.com run_01M1BREJZK5V77ZRPXKTG7ZG7C \
--out ./run-artifacts--inputs is a JSON object of kickoff values keyed by the names in the
workflow's on.manual.inputs. A workflow that takes none still needs a file
containing {} — the harness refuses a start with no inputs parameter rather
than silently running on defaults.
A file input's value is a local path or an https:// URL. Either way the
driver ends up with a registered File ref in the URL, because that is what the page
validates: a whole { path, name, contentType, size, url }, never a bare string.
- A local path is registered through the page (
files/prepare→files/register) and its bytes are streamed from disk to the bucket from Node (a 600 MB recording never becomes one base64 string; the file must be complete when the driver starts). Under--mocksthe PUT goes through the page instead, so the mock backend sees it. - A URL is downloaded first, streamed to the runner's temp dir, then PUT to the
bucket from Node with an explicit
Content-Length(a presigned PUT refuses a chunked body), and registered. The stored object is named fromContent-Dispositionor, failing that, the last path segment of the URL you gave (…/anatomy.mp4→anatomy.mp4), so the run and its outputs carry the recording's name. A non-2xx download, a body over the files trio's 5 GB cap, or a URL under--mocksis a driver-side fault (exit2) naming the input and the URL. This is how a run started over the harness's MCP endpoint (workflow.start, ADR-0006) takes a recording: the caller passes the URL, the dispatched job does the fetch. A URL wrapped in an object ({ url: "https://…", name? }) is treated as that URL too — a registered File ref (apathunderworkflows/) is passed through instead, even if it also carries aurl.
A list: true file input takes an array, mixing paths and URLs per entry.
Options
| flag | |
|---|---|
| --inputs <file> | the kickoff values (required) |
| --out <dir> | where the artifacts are written; omit it and none are |
| --timeout <60m> | how long to wait for a terminal status (ms/s/m/h; a bare number is seconds) |
| --wait <fail\|park> | what to do at a step that needs a person (default fail); see Driven runs |
| --run-id <run_…> | (run) the run's pre-minted id, so a --wait park run and its resume share one |
| --grace <5m> | how long a parked run is watched for its answer before the job ends |
| --drive-key <key> | (run, resume) sent as x-workflow-drive-key on every request the driven page makes; wins over WORKFLOW_DRIVE_KEY — see Environment |
| --mocks | drive the dev harness's MSW mock backend, and skip the login (see the note below) |
| --headed | show the browser |
| --last <n> | (runs) how many past runs to list |
| --all | (runs) every run of the workflow, not just yours (adds &scope=all). Needs a harness with run ownership (spec 11); an older harness ignores the flag and already lists everyone's runs |
--timeout bounds each follow leg — from a start or a resume to the next park or
terminal status — not the job as a whole: a run that parks and is later resumed can take up
to N × --timeout, because a resumed run has just been answered by a person and holding it
to what was left of the first leg's budget would time out a run that is moving. Each leg's
start is separately capped at 120 s (or --timeout, if shorter): a harness that has not
published a runId by then is not slow, it is wrong, so a generous --timeout never turns a
bad harness url into an hour of waiting.
Environment
One of the first two is required unless --mocks; the token wins when both are set.
| variable | |
|---|---|
| WORKFLOW_APP_TOKEN | the whole login. An app token (bfat_…, Settings → App Tokens on the harness's admin) minted with the scopes workflow:read workflow:run workflow:files auth:session. The driver signs the browser in from it through CE's session exchange (POST admin.<domain>/api/auth/session/from-app-token, CE ≥ 0.4.50 — apps#588) and sends it as Authorization: Bearer on every /api/workflow/* call it makes: it is the member, narrowed to its scopes (spec 10, D23). Wins over WORKFLOW_TOKEN |
| WORKFLOW_EMAIL / WORKFLOW_PASSWORD | the fallback: a member login through the harness's admin relay, used only when no app token is set (and the only path against a CE without the exchange) |
| WORKFLOW_TOKEN | optional, sent as X-API-Key on /api/workflow/* reads |
| WORKFLOW_DRIVE_KEY | optional fallback for --drive-key, set by workflow-drive.yml from client_payload.drive_key |
The credential the harness page honours is a session cookie — a private
deployment's document load carries no header, and two of the harness's relays
forward the caller's cookies. WORKFLOW_APP_TOKEN gets one without a form: the
exchange runs through the browser context's request client, so the cookies CE
sets land where the page's own fetch reads them, and the harness opens signed
in. auth:session is the scope that gate checks; a token without it is refused
with exit 2 — app token login refused (403 insufficient_scope): the token
must be minted with the auth:session scope — and so is a token for another
project (token_project_mismatch), a revoked or expired one (401), and a
harness whose CE predates the exchange (404, with a hint to fall back to
email/password). An API key cannot mint a session at all, so WORKFLOW_TOKEN
is an extra on top of the session — never a replacement for it.
Artifacts (--out)
run.json the /api/workflow/run?id=… record, verbatim: { run, steps }
outputs/ every File-ref output, named after the output (poster.svg, posters-1.png…)
steps.log one timestamped line per status transition
console.log the page console
01-start.png the run page, just after the start settled
01-resume.png (resume) the run page, just after it re-opened with ?resume=1
02-<status>.png the run page at its terminal status
03-parked.png the run page at a step that needs a person (--wait park)
03-stalled.png the run page when it stopped driving (exit 6)
failed.png written whenever the run did not succeedA start that times out (exit 4) writes failed.png, console.log and steps.log too. That
is the one refusal with no record to fall back on — every refusal the page can explain arrives
as exit 3 — so the page's console is the whole diagnosis.
The run's status is run.json.run.status, and each step's settled status is a row of
run.json.steps ({ key, status, … }). Read verdicts from there rather than
from steps.log, which is a 1 s sampler: it can miss a status a run passed
through, so it is a narrative, not proof that something never happened.
A parked run's own record only says the row is still running; which step keys it is
waiting on is the driver's own read of the page (currentSteps, not a field of the record),
reported as parkedOn on the library's RunReport and as the CLI's own
parked: <run id> (<step keys>) line on stdout — not something run.json itself carries.
An output is saved when its value is a File ref, not when its declared type
is file: a run-level outputs: entry that simply forwards a step's file
(poster: ${{ jobs.card.outputs.poster }}) declares no type at all, so type is
not something this side can filter on. A render: island output that carries a
ref is therefore saved too. An output that will not download is reported and
skipped — one unreadable ref never costs you the rest of the artifact set.
--mocks
--mocks points the driver at the dev harness's in-browser MSW backend, which
is how this package is smoke-tested without a deployment. One dev-only wrinkle:
the mock's storage lives in page memory, and the start URL is a fresh
navigation, so bytes uploaded for a file input are gone by the time a step
asks for them. The ref itself is correct and the run is real — only a download
of a mock-uploaded input 404s. Against a deployment the bytes are in the bucket
and this does not arise.
Driven runs (--wait park and resume)
By default a run that reaches a step needing a person is followed to whatever
the harness makes of it — for an undeclared interactive step, a failure. That
is right for a workflow meant to be unattended and wrong for one where a human
approval is a step, so --wait park says so:
workflow-headless run https://workflow.example.com hello/interactive \
--inputs inputs.json --wait park --grace 10m --out ./run-artifactsAt such a step the page hands the run back — the row stays running, and the
lease is released so a person's tab can take it — and the driver stops driving.
It then watches the record for --grace, re-reading it every 10 s:
- answered inside the window — every parked step's row has settled and
nobody took the lease: the driver re-opens the run page with
?resume=1and finishes the run in the same job; - the window ran out — exit
0, withrun.jsonand aparked: <run id> (<step keys>)line saying where it stopped; - someone else took the lease — a person's tab, or a second job: exit
0the same way, and the run is left to them.
resume picks up from the second and third cases later. It is given the run id
alone — impl/workflow come off the record — and it adopts the lease without
a person clicking Resume, then follows the run home under the same park rules
(a second interactive step parks again). A run that has already ended is
reported at its own status without being opened; a run someone else is driving
is exit 5, untouched.
Pair --run-id with --wait park when the id has to be known before the run
exists — a job that posts "approve here" links, say: the kickoff page inserts
the row under that id, and the later resume names the same one.
The full contract for parking, the lease, and who is allowed to resume what is spec 07 §"Driven runs — park and resume (ADR-0006)" and ADR-0006.
Exit codes
| code | |
|---|---|
| 0 | the run succeeded, or parked: it waits on a person and run.json says where |
| 1 | the run failed or was cancelled |
| 2 | usage, an unreadable --inputs, a refused login, or any other driver-side fault (a failed upload, an API read that would not answer, an unexpected exception) — deliberately never 1, so if: failure() can tell "the run failed" from "the driver could not reach the harness" |
| 3 | the page refused the start (status: 'invalid') — bad values, an undecodable inputs, a workflow that does not lint or could not be read, no such implementation/workflow, or a discovery failure |
| 4 | the driver timed out (the run may still be going) |
| 5 | another tab or job holds the lease, so nothing was driven — retryable, and deliberately neither 1 nor 2 (reachable from resume, or from a run --wait park that resumed after its grace window and lost the race for the lease) |
| 6 | the page stopped driving mid-leg: it published paused (the page state apps#715 adds), or the record's lease has an owner and had lapsed while the page still said running (re-read every 10 s). Nothing was clicked or written — the row is still running, and run.json plus a stalled: <run id> (<step keys>) line say where. A resume adopts the expired lease; whether that is the right move depends on why the page stopped — a throttled tab resumes cleanly, a failed step write (run.json's pause reason) would only fail again. Deliberately not 4: a timeout says the run may still be going; this says the page it was on is not |
| 130 | SIGINT: the driver was interrupted — before the run page exists it closes the browser and leaves; once the run is up it clicks Cancel and follows the run to cancelled first (see Signals) |
Exit 3 is watched on window.__workflow, not on the kickoff-invalid
element: only two of the six refusals render that list, and the four that do
not are the likeliest ways a CI run goes wrong.
Signals
SIGINT is the driver's own, from the moment the browser exists: before a run
page is up it closes the browser and leaves with 130; once the run is
running it clicks Cancel and waits for the run to reach cancelled first, so
CI's record says the run was cancelled rather than that the driver vanished. A
second Ctrl-C closes the browser and leaves without waiting.
SIGTERM and SIGHUP stay Playwright's, whose handlers close the browser but
do not exit the process. So the driver survives the signal, its in-flight
call rejects against a browser that is no longer there, and that lands in the
driver-fault branch: a CI job cancellation ends as exit 2 with the run left
running — not 130, and not 1, which stays reserved for a run that
really did fail. Send SIGINT if you want the run cancelled first.
In CI
Two pieces of wiring live in bffless/apps, and they are the
worked examples for anyone wiring this up elsewhere:
.github/workflows/workflow-headless-run.yml— the live run.workflow_dispatchonly, on purpose: a run there writes a real row, uploads to the deployment's storage and calls whatever pipelines the workflow calls. It takesworkflow,inputs,harness_urlandtimeout_minutes(digits only), passesWORKFLOW_EMAIL/WORKFLOW_PASSWORDfrom repo secrets (aWORKFLOW_APP_TOKENsecret minted withauth:sessionwould do alone — see Environment), uploadsoutput/as an artifact and summarises the run id and status. Note that cancelling the job sendsSIGTERM, which is exit 2 with the run leftrunning(see Signals) — hencecancel-in-progress: false.apps/workflow/e2e/headless.spec.ts— the end-to-end proof of this package, with no deployment involved: Playwright spawns the builtdist/cli.jsagainst the harness dev server in--mocksmode and reads the artifacts back off disk. It fails, rather than skipping, whendist/cli.jsis missing — sopnpm --filter @bffless/workflow-headless buildcomes beforetest:e2ein CI.
The driver job an implementation repo runs for a driven run (workflow-drive.yml, written
by @bffless/workflow init) carries one secret, WORKFLOW_APP_TOKEN
— an app token minted with workflow:read workflow:run workflow:files auth:session — and no
password: the token signs the browser in and authorises every call the driver makes.
There is deliberately no bffless/run-workflow GitHub Action yet; a dispatch workflow calling
the CLI is all a single repo needs.
As a library
import { launchBrowser, runWorkflow } from '@bffless/workflow-headless'
const browser = await launchBrowser()
const report = await runWorkflow(
// `appToken` alone signs in; `credentials: { email, password }` is the relay fallback.
{ harnessUrl, impl: 'hello', workflow: 'interactive', inputs: {}, timeoutMs: 1_800_000, mocks: false, appToken },
{ browser, log: console.log },
)
await browser.close()Everything below the CLI talks to a PageLike/BrowserLike seam rather than
to Playwright, so the unit suite launches no browser.
Page tools (WebMCP)
The harness page registers its agent tool catalog on document.modelContext
(spec 10),
polyfilled when the browser has no native WebMCP. listPageTools,
waitForPageTools and callPageTool drive those tools through page.evaluate
— how a walk proves the catalog against a real deployment with no agent host.
It is the same catalog the harness serves over its MCP endpoint
(POST /api/workflow/mcp, app-token fronted — WORKFLOW_APP_TOKEN above is
how the driver carries one); the page tools run in the page, with its session:
import { callPageTool, waitForPageTools } from '@bffless/workflow-headless'
await waitForPageTools(page, { timeoutMs: 30_000 })
const started = await callPageTool(page, 'workflow.start', { impl: 'hello', workflow: 'interactive', inputs: {} })
const done = await callPageTool(page, 'workflow.await', { until: 'terminal' })callPageTool resolves to the CallToolResult — an isError refusal included,
since that is an answer to assert on — and throws PageToolError only when the
bridge itself fails (no registry, no such tool).
