rx-controls-suite doesn't compete
with orchestrators — it feeds them. Bluesky, Prefect and n8n each try to break that
claim from a different angle, and each reports what actually broke.
Flow/Task/Run/Artifact/State all assumed a Python process underneath. Workflow/Node/Execution assume nothing — every node is just an HTTP call in and a JSON blob out.
No shared Python objects between steps. Whatever state the scan needs has to live somewhere else — see slide 6.
Context and an rxtango event
loop against a real beamline.scan_service.py, :8030).Switch routes the cursor's
action · Wait parks on beam loss, resumed by webhook
scan_core.py
imports nothing from n8n or Prefect. Only the two layers above it are new.
RxLoop, the caproto
Context, the shared
health observable, the open
ScanRun, the sweep cursor —
plain Python objects, passed between persist_result=False
tasks in one process.
Every step is a separate HTTP call. That state has to become a
server-side session (ScanSession
in scan_service.py) — n8n
is left holding only control flow.
refine.py::assess — converged, refine, or exhausted./sweep and
/refine are sync def
endpoints so they can block inside drain —
the same rx-loop-thread boundary the Prefect talk covered, just crossed by FastAPI's
threadpool instead of a Prefect task.
/next returns one decision, and
exactly one Switch node fans it
to five branches — sweeps and refinement iterations collapse into the same loop.
n8n has a documented bug: a Wait-on-webhook node inside a
Loop Over Items gets its item
reprocessed on resume. One Switch
around a session-owned cursor sidesteps it entirely — there's no loop node to
re-enter, just a decision that gets asked again.
Loop Over Sweeps node —
changed for exactly this reason.
prepare_beamline → run_sweep ×3 →
finalize
Assess → Next re-enters the
Switch when quality is below target.
max_iterations.orbit_drift
injected · 75% quality (9 / 12 OK)sustained_low → Wait nodevacuum_burstorbit_drift, clearedorbit_drift, heldmax_iterations guardThreadPoolScheduler(1)
first — resume_flow_run's
@async_dispatch sniffs a running
loop and silently no-ops otherwise.GET $execution.resumeUrl
— no thread-local run context to respect, fired straight from
on_next.scan_service.py
serves its own instrument-panel dashboard at :8030
— the coverage strip, loop/machine/beamline panels, a per-frame SSE log.live_dashboard.py
(:8000) tracks an n8n-driven
scan unchanged — it only ever read the
TOMO:SCAN:* PVs, which this
service writes exactly as the plain script did.scan_report.ipynb
opens the resulting HDF5 unchanged too — same compound dtype, same
projections dataset.demo/workflow-engines/README.md — run it yourselfdemo/workflow-engines/n8n_workflows/*.json — both workflows, importable as-is