medOS ultra

Flow Editor Live-Run Mode

Live-run / simulate mode for the main flow editor over the read-model layer.

11 min read diagramsUpdated 2026-06-04docs/architecture/flow-editor-live-run-mode.md

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-dispense blocked until paymentStatus === '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)

  1. Multi-active nodes. One encounter occupies several nodes at once (parallel billing ‖ pharmacy ‖ coding tracks). runState is a Map<nodeId, …>, not a single “current node.” The renderer must light up a set.
  2. Dual-ID shapes. department_queues rows are Supabase (id UUID, encounter_id text); 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 — see web/CLAUDE.md).
  3. 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 whose selectors don’t match the orchestrator’s emitted dept_type will simply never light up (good signal for the dead-dept_type class of bug the audit found).
  4. Read-model is read-only. Live mode advances via the backend (REST → events → orchestrator → rows). Never write department_queues from the editor. Simulate mode writes only to local run-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_cache for 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-gates to 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. assertTransitionPASS / 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_stale view).
  • web/supabase/migrations/20260604b_harness_teardown_cron.sqlharness_sweep_stale() RPC + cron_jobs registration (daily ledger sweep).
  • infrastructure/medbase/functions/harness-teardown/index.ts — scheduled/HTTP server sweep (best-effort REST cancel via GATEWAY_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 GlobalModalRenderer to 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 (/sandbox or real clinic) capability; the standalone ?target=WorkflowCockpit is MOCK-only.
  • field_match templates need dept-queue rows in the cache. The observer reads pending_tickets/department_queues/order_queue off the journey cache; if a deployment names that array differently, pass extractDeptRows.

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 (WorkflowProviderworkflow.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 defensive editFlowTemplate fallback (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.tsx renders as a fixed full-viewport overlay (covers the app nav) + a floating back button.
  • Not-found, not infinite spinner: LayoutMain.tsx split 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 where workflowTemplate resolved but workflowDetail hasn’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 real IntegratedWorkflowEditor mount in the lightweight web/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).

Ask Anything