Flow Editor Live-Run Mode
Live-run / simulate mode for the main flow editor over the read-model layer.
Status: Design (not built). Companion to the Main Flow Editor (web/src/common/components/medical/builder/main-flow-editor/) and the read-model layer (docs/architecture/encounter-orchestrator-triggers.md).
Goal
Let an operator drive a real or mock patient encounter through a workflow graph inside the editor and watch it happen:
- active node(s) light up as the patient advances through
department_queues, - a gate node turns red and “sticks” when its predicate blocks the flow (e.g.
pharmacy-dispenseblocked untilpaymentStatus === 'paid'), - the operator advances the patient either by opening the node’s real modal (a true transition) or by injecting a synthetic event (a simulated one).
This turns the editor from a static authoring canvas into an end-to-end test + demo harness for the OPD/IPD flows.
Why it’s ~60% already built
| Capability | Already in repo | Where |
|---|---|---|
| Per-node run status + glow | NodeRunningStatus (NotStart/Running/Succeeded/Failed/Exception), _runningStatus on node data, WorkflowRunningData.tracing, “Last Run” panel tab |
components/workflow/types.ts, nodes/_base/components/workflow-panel/tab.tsx |
| Live queue feed | realtime subscription binding a node to its department_queues rows |
useFloorPlanQueueSubscription, workflowFloorPlanService.ts |
| Node ↔ live-row matcher | the workflow JSON selectors block already maps each node → a dept_type + status filter |
medical-kit/src/medical-worklist/defaults/*-workflow.json (selectors) |
| Gate evaluation | usePolicyGate / evaluateGates + workflow-JSON paymentGate field check |
policy_gates engine, opd-discharge-workflow.json |
| Real transition path | proven REST recipe: login → POST /v2/medication/orderRequests → propagates to hospital_events → department_queues in ~4s (PH demo) |
e2e order propagation recipe |
| Modal launch | GlobalModalRenderer mounts the real station modals |
DialogScreeningDrugLeaves, DetailDischargeDialog, DialogRecordCoder |
The missing ~40%: an adapter that maps live department_queues rows → node _runningStatus, plus a small run-controller and a “stuck gate” renderer.
Architecture
┌─ Run toolbar (operator/) ─────────────────────────────┐
│ [▶ Live] [▶ Simulate] patient: [picker] ⏹ Stop │
└───────────────┬───────────────────────────────────────┘
│ encounterId
▼
useFlowRunController(encounterId, mode)
├── Live: subscribe department_queues + encounter_journey_cache (realtime)
└── Simulate: local state machine walks the workflow JSON edges
│
▼
selectorMatch(row, node.data.selectors) ← reuse the JSON `selectors`
│ → Map<nodeId, NodeRunningStatus>
▼
for each active node: evaluateGate(node, ctx) ← usePolicyGate / paymentGate
│ → { blocked, reason }
▼
node render: _runningStatus glow + red "STUCK: <reason>" badge on blocked gates
New pieces (small)
| File (new) | Responsibility |
|---|---|
components/workflow/run/use-flow-run-controller.ts |
Own the run session: subscribe (Live) or step (Simulate); produce Map<nodeId, RunState> where RunState = { status, queueRow?, blocked?, reason? }. |
components/workflow/run/selector-match.ts |
Pure: given a department_queues row and a node’s selectors rule, return whether the row belongs to that node. Lifted straight from how the worklist already reads _raw.dept_type selectors. |
components/workflow/run/run-store.ts |
Standalone Zustand (mirrors node-layer-view-store.ts): { mode, encounterId, runState: Map, setMode, ... }. Zero blast radius. |
components/workflow/operator/run-toolbar.tsx |
Mode switch + patient picker + stop. Sits in the operator bar next to the layer-view toggle. |
nodes/_base/run-overlay.tsx (or extend the existing CustomNode wrapper) |
Apply the glow from runState.status; render the red STUCK badge when runState.blocked. Reuse the same CustomNode wrapper already added for the layer-view dimming. |
Two fidelity modes
| Mode | Advances by | Hits backend | Use for |
|---|---|---|---|
| Live | real REST calls → real events → real department_queues rows; node glow driven by realtime |
✅ PH demo | true UAT / customer demo |
| Simulate | a local state machine over the workflow JSON edges; “Advance” injects a synthetic dept_type/status or flips metadata.paymentStatus |
❌ no writes | fast logic/gate demo, no test patient, no infra |
Both share the same renderer — only the source of runState differs.
The node↔row matcher (the key reuse)
Every discharge/worklist workflow JSON already contains exactly the mapping we need:
"selectors": {
"pharmacy-dispense": { "strategy": "field_match", "path": "_raw.dept_type", "operator": "==", "value": "pharmacy_dispense" },
"billing-paid": { "strategy": "field_match", "path": "_raw.dept_type", "operator": "==", "value": "billing",
"additionalFilters": [{ "path": "_raw.status", "operator": "==", "value": "COMPLETED" }] }
}
selector-match.ts evaluates these against each live row to decide which node a row “is on.” No new mapping to invent — this is the same accessor model the worklists already use.
Hard parts (call these out before building)
- Multi-active nodes. One encounter occupies several nodes at once (parallel
billing ‖ pharmacy ‖ codingtracks).runStateis aMap<nodeId, …>, not a single “current node.” The renderer must light up a set. - Dual-ID shapes.
department_queuesrows are Supabase (idUUID,encounter_idtext); the REST transition path wants Mongo_id/encounter(ObjectId). The controller must carry both and never send a UUID to a NestJS endpoint (the recurring dual-source trap — seeweb/CLAUDE.md). - Config-time positions vs runtime rows. Node x/y is authored; rows are runtime. The matcher binds them by
dept_type/status, not position — so a workflow whoseselectorsdon’t match the orchestrator’s emitteddept_typewill simply never light up (good signal for the dead-dept_typeclass of bug the audit found). - Read-model is read-only. Live mode advances via the backend (REST → events → orchestrator → rows). Never write
department_queuesfrom the editor. Simulate mode writes only to localrun-store, never Supabase.
Phased rollout
- P0 — Simulate only.
run-store+selector-match+ the local state machine + node glow + STUCK badge. No backend. Demos the graph + gate logic in seconds. (Ships the whole renderer.) - P1 — Live read. Swap the source: subscribe to
department_queues/encounter_journey_cachefor a picked encounter; glow follows real rows. Read-only — no transitions yet. - P2 — Live advance. Wire the active node’s modal (via
GlobalModalRenderer) so the operator performs the real transition; watch the next node light up from realtime. - P3 — Gate authoring loop. From a stuck gate, deep-link to
/admin/policy-gatesto edit the blocking rule, then re-run — closing the author→test loop.
Relationship to the layer-view toggle
The layer-view toggle (node-layer.ts + node-layer-view-store.ts, shipped) and this run mode share the same CustomNode wrapper in nodes/index.tsx. Layer view answers “what kind of node is this” (station vs logic); run mode answers “what is the patient doing right now.” They compose: run in Physical view to watch a patient walk the stations; switch to Logic view to watch which engines (CDS, auto-assign, claim) fired.
Implementation status (built)
Shipped as the Workflow Test Cockpit under components/workflow/cockpit/ — two coexisting overlays sharing the ReactFlow store (mounted in IntegratedWorkflowEditor.tsx, authoring mode only):
| Overlay | Launcher | Mode | Files |
|---|---|---|---|
| Test Cockpit | purple FAB, bottom-left | MOCK (P0) — local walk + gate precheck + canonical oracle | WorkflowTestCockpit.tsx, use-workflow-cockpit.ts, cockpit-fixtures.ts, node-routes.ts, types.ts |
| Live Run Cockpit | emerald FAB, bottom-right | LIVE (P1–P3) — real synthetic patient → read-model observation → oracle | harness/CockpitLivePanel.tsx + the harness modules below |
The harness (components/workflow/cockpit/harness/)
| File | Phase | Responsibility |
|---|---|---|
assertions-core.ts / assertions.ts |
oracle | Canonical-transition oracle bound to MASTER_WORKFLOW_NODES. assertTransition → PASS / ILLEGAL_TRANSITION / ORACLE_UNDEFINED (abstains on non-canonical nodes — never false-flags editor/pipeline nodes). assertNoFork proves two wards traverse the same canonical state sequence from OBSERVED states. |
selector-match.ts |
P1 | UNIFIED pure matcher faithful to BOTH real selector grammars (field_match/compound over _raw.* and manifest_path/legacy_status_bucket over encounter.* with rules[]+mode). Path resolution mirrors production resolvePathOnRow (resolved-row groups → _raw fallback). Parity asserted against the real discharge-pipeline-workflow.json in tests. |
selectors-source.ts |
P1 | Resolves { selectors, manifestBindings } from a curated set of real authored workflows (discharge-pipeline, nurse-ipd, doctor-outpatient, opd-discharge, cashier, pharmacy-full) or from the editor’s loaded template. |
use-observer.ts |
P1 | READ-ONLY. Subscribes to the synthetic encounter’s encounter_journey_cache, resolves rows with the production resolveRows, and matches both grammars (cache row for manifest_path, embedded dept rows for field_match). Surfaces currentNodes + stuck. |
patient-driver.ts |
P2 | Mints a REAL ZZ_HARNESS_-tagged patient + encounter via domain REST (DRY-RUN by default; writes only on live:true). Read models stay read-only. |
use-flow-run-controller.ts |
P2 | Owns the LIVE session: driver → observer → paints canvas nodes → runs the oracle on each REAL consultationWorkflowState transition → projects lifecycle into harness_runs. |
run-store.ts |
P2 | Standalone Zustand (zero blast radius) for the LIVE session state. |
use-harness-runs.ts / teardown.ts |
P5 | Run-history reader (realtime) + teardown (single-run, client sweep, server-sweep invoke). |
no-fork.ts |
P3 | Per-ward no-fork ledger + assertNoFork compare. |
route-context.ts |
P2/P3 | Context-carrying deep links to the real station pages + the /admin/policy-gates author loop. |
Backend / ledger
web/supabase/migrations/20260604a_harness_runs.sql— the LIVE run ledger (harness_runs+harness_runs_staleview).web/supabase/migrations/20260604b_harness_teardown_cron.sql—harness_sweep_stale()RPC +cron_jobsregistration (daily ledger sweep).infrastructure/medbase/functions/harness-teardown/index.ts— scheduled/HTTP server sweep (best-effort REST cancel viaGATEWAY_URL/GATEWAY_SERVICE_TOKEN+ ledger mark).
What remains (genuine follow-ups)
- P2 live-advance via real modals. The controller observes transitions and deep-links each stage’s page; wiring
GlobalModalRendererto perform the transition in-overlay (instead of opening the page) is not done. - LIVE in the standalone sandbox. The Live overlay mounts in the sandbox but real writes need auth — LIVE is a full-app (
/sandboxor real clinic) capability; the standalone?target=WorkflowCockpitis MOCK-only. - field_match templates need dept-queue rows in the cache. The observer reads
pending_tickets/department_queues/order_queueoff the journey cache; if a deployment names that array differently, passextractDeptRows.
Session update — 2026-06-04: editor data-source homogenization + shell + OPD gate
This run extended the cockpit (above) and, more importantly, homogenized the Workflow Store and the editor onto a single store, fixing a class of “workflow won’t open” dead-ends. Shipped in commit 837675bca.
One store: Supabase workflow_templates is canonical
The editor (WorkflowProvider → workflow.service.ts) reads/writes Supabase workflow_templates — the real ReactFlow graphs, with clinic_id/subclinic_id + nodes. The Workflow Store page (containers/workflow-store/page.tsx) previously listed a different store: the Mongo/REST editFlowTemplate catalog (GET v2/administration/editFlowTemplates, a flat data: [{ name, type, data, id }] item list, mostly empty stubs). The two have disjoint id spaces, so clicking a store/recents item navigated /workflow-editor/:id to an id the editor couldn’t find → PostgREST 406 (0 rows) → LayoutMain looped on the loading spinner.
Fix (code-only, no SQL — the table already holds ~43 real rows): page.tsx now sources its catalog from getWorkflowTemplateService(medbase).getWorkflowTemplates() (the same Supabase store the editor uses). The department list filters that catalog by clinic_id/subclinic_id. Verified against the live backend: the clinic-picker’s id equals workflow_templates.clinic_id (both UUID — e.g. clinic e93f41db… owns “Nurse Outpatient Department Workflow”). So catalog → Quick Access → recents → editor are one store, and everything opens. editFlowTemplate + department_flow_mappings are no longer the catalog source (the destructured mappings/getMappingsByDepartment are now unused).
New-build note: this is a clean cut to the canonical store — no migration / back-compat for
editFlowTemplate(its content was empty stubs). The editor keeps a defensiveeditFlowTemplatefallback (WorkflowProvider.mapEditFlowTemplateToWorkflow) only so a lingering localStorage recent pointing at the old store still opens (adapts its item list → nodes) instead of dead-ending. Safe to delete once recents rotate.
Editor shell
- Full-screen (Miro-style):
containers/workflow-editor/page.tsxrenders as a fixed full-viewport overlay (covers the app nav) + a floating back button. - Not-found, not infinite spinner:
LayoutMain.tsxsplit the gate —isLoadingWorkflow→ spinner; loaded-but-null → a “Workflow not found” screen with Back to Workflow Store + Remove from recent (prunes dangling localStorage entries). Also covers the 1-render lag whereworkflowTemplateresolved butworkflowDetailhasn’t derived yet (so it never flashes not-found). - Sandbox mountability: wrapping the editor target in
MedBaseProvider(trivial — just supplies the supabase client) is what lets the realIntegratedWorkflowEditormount in the lightweightweb/sandbox(?target=OpdEditor/WorkflowCockpit).
Cockpit: order_procedure gate (OPD day-procedure routing)
Extended the MOCK cockpit beyond discharge_patient: added procedureAuthorization to the synthetic patient, an order_procedure FIXTURE_GATE (predicate procedure.authorization == approved), per-trigger remediation (Authorize vs Settle), and an inline policy_gates-row inspector in the halt banner. sandbox/targets/WorkflowOpdEditorTarget.tsx drives appointment → screening → consult → order-procedure → [gate] → day-procedure flow on the real editor.
SimpleNode chrome (simple-node/index.tsx)
Added a source handle (so outgoing edges from simple nodes render) + a guarded ★ priority badge (data.priorityField/data.priority). Optional priorityField/priority/route added to CommonNodeType.
Workflow Store UX (page.tsx + WorkflowStoreHero.tsx)
- Phase bar counts the global catalog (it sits above the clinic picker) + fixed the missing Discharge bucket; a per-clinic count appears once a clinic is picked.
- Recently edited = board-style cards with a mini node-graph preview (per-id accent) + node tooltips.
- Quick Access (shown when the active phase’s catalog is small) = card⇄list toggle + descriptions (real, or an auto node-summary via
workflowSummary()). - Linked Clinic → Sub-Clinic cascade dropdowns (filtered by
clinicRef) + adaptive indication line. - Hero = node-family legend + a DOM/SVG node-graph (the constellation “baked” into real workflow-node icon chips) + per-node tooltips; the 3D constellation is dimmed to a backdrop on md+.
Files
containers/workflow-store/page.tsx, containers/workflow-store/WorkflowStoreHero.tsx, containers/workflow-editor/page.tsx, main-flow-editor/LayoutMain.tsx, main-flow-editor/contexts/WorkflowProvider.tsx (commit 837675bca); plus earlier simple-node/index.tsx, components/workflow/cockpit/*, sandbox/targets/WorkflowOpdEditorTarget.tsx (commit 4e1beb725).