Skip to content

Modeler internals and limitations

Orchestra is edited through the Modeler API by two editors:

  • the BPMN.io canvas (orchestra_bpmn_io), and
  • the Complete modeler form (orchestra_cm).

Both go through a Modeler API model owner (orchestra_modeler) that maps Orchestra's nodes and flows onto the Modeler API's generic components and back. This page documents that mapping, the adaptations Orchestra layers on the BPMN.io editor, and the Modeler API / BPMN.io limitations Orchestra has to work around. It is a maintainer reference; for the user-facing overview see Integrations and authoring.

The model-owner bridge

The model owner (Drupal\orchestra_modeler\Plugin\ModelerApiModelOwner\Orchestra) translates between the workflow config entity and the Modeler API's Component objects:

  • Nodes become components. A start node maps to a start component, a gateway to a gateway component, every other node to a generic element component. The task type id is the component's plugin id; the node's task config plus its join / split plugin ids become the component configuration.
  • Flows become successors. Each node carries a successor per outgoing flow. A conditioned flow additionally emits a link component that shares the flow id and carries the condition plugin and its settings; an unconditioned flow emits no link.
  • Flow conditions are Orchestra-specific. The FlowCondition plugin type (comparison, count, all, any) lives entirely in Orchestra. The Modeler API knows only a generic COMPONENT_TYPE_LINK; the owner maps Orchestra's conditions onto that link type. Neither modeler_api nor bpmn_io knows what a flow condition is.

On save the owner resets the graph and rebuilds it from the components the editor posts back: resetComponents() captures node-level data the components cannot carry, addComponent() recreates each node and flow, and finalizeAddingComponents() reattaches the captured data.

BPMN.io adaptations

orchestra_bpmn_io swaps in a LayoutAwareBpmnIo modeler (it keeps the bpmn_io plugin id, so it is never a separate modeler) and attaches a few client behaviors, for the Orchestra owner only:

  • Stored layout. Orchestra stores each node's position and size and each flow's waypoints itself. The stock converter drops nodes at random and runs an auto-layout, so layout_restore.js snaps everything back after a convert.
  • Node colors. Stored as third_party_settings.modeler_api.colors (a modeler_api-managed map of element id to fill/stroke) and re-applied on every convert, so a config-only workflow still renders colored.
  • Context pad restriction. The pad is trimmed to the shapes Orchestra can model, so it never offers a native end event, intermediate event or shape-morph the workflow would silently drop on save.
  • Palette clearance. The first fit lays the diagram edge to edge, leaving the leftmost element under the palette; fit_clear_palette.js nudges it clear.
  • Node-id preservation. See the limitations below.

Layout storage

Orchestra keeps the diagram layout in the workflow's own config, independent of any one modeler's native format. That is what lets a config-only example (shipped with no BPMN blob) still render a hand-authored diagram, and lets the Complete modeler and BPMN.io edit the same workflow without one wiping the other's positions.

Where it lives

The layout config property is a map keyed by modeler id (today bpmn_io). Each modeler's entry holds:

  • nodes: bounds (x, y, width, height) keyed by node id;
  • flows: a list of waypoints ({x, y}) keyed by flow id;
  • flowLabels: external flow-label bounds keyed by flow id;
  • nodeLabels: external node-label bounds keyed by node id (events and gateways carry their name in a separate label element).

This is separate from third_party_settings.modeler_api.data, which is bpmn_io's own BPMN XML blob. The structure that matters (nodes, flows, conditions) is the Orchestra config; the BPMN blob and the layout map are two views of the presentation. Keeping layout in Orchestra's config is what keeps a workflow portable as plain YAML and modeler-agnostic.

How it is written

On a BPMN.io save, an orchestra_workflow_presave hook (captureLayout) parses the freshly stored BPMN diagram interchange (DI) and copies it into layout.bpmn_io: each BPMNShape's bounds become a node's (and its label's) bounds, each BPMNEdge's waypoints become a flow's (and its label's) bounds. It runs only when the saving modeler is bpmn_io (modeler_id == bpmn_io); a save by any other modeler leaves the last-known layout untouched, so switching editors never wipes the diagram.

How it is restored

The stock converter drops nodes at random and runs an auto-layout, so LayoutAwareBpmnIo::convert() ships the stored layout (and each flow's endpoints, see below) to the page, and layout_restore.js (once the converter's own fit has settled) snaps every node back to its stored bounds and every flow to its stored waypoints. A workflow last edited in another modeler, or one shipped config-only, therefore opens with its intended diagram instead of a scrambled auto-layout.

Limitations and workarounds

These are points where the Modeler API or BPMN.io does not, on its own, preserve what an Orchestra workflow needs. Each is handled Orchestra-side, with no patch to modeler_api or bpmn_io.

BPMN.io rewrites node ids on edit

loadConfigForm() in bpmn_io.js rewrites any element id that does not start with its pluginid the moment the node is selected for editing, to {pluginid}_{suffix} (the suffix is the last _-segment of the old id). It is a convention meant to give freshly dragged ECA elements (which bpmn-js ids as Activity_xxx) readable, plugin-prefixed ids. The plugin itself is resolved from a pluginid property, not the id, so the rewrite is cosmetic.

Orchestra opens existing workflows whose ids do not follow that convention (n_review_1, n_tally), so selecting a node renamed it (n_tally to script_tally, n_review_1 to user_1), orphaning the stored layout (keyed by the original id) and the workflow's own flow references.

Workaround: preserve_node_ids.js wraps loadConfigForm and drops the lone id-only updateProperties() call it makes, leaving the authored id intact; everything else loadConfigForm does runs unchanged. A FunctionalJavascript test invokes the select-for-edit path and asserts the id survives, so a future bpmn_io change that breaks the guard fails loudly.

The component carries only the join/split plugin id

A component transports a node's task config plus its join and split plugin ids, but not those plugins' own settings (node.join.settings). A wait_all join's merge policy (collect / into / scope, the heart of a quorum tally) and any split settings have no slot on the component, so a modeler save dropped them silently.

Workaround: the owner captures the join/split settings in resetComponents() (the same way the staged timers ladder is preserved) and layers them back onto the rebuilt routing in finalizeAddingComponents(), without overwriting the plugin id the editor round-trips.

Authoring: join and split are configurable plugins, so they contribute their own settings forms (a merging join, wait_all or matching, exposes collect / into / scope). The Complete modeler renders that form under the plugin picker and writes the authored values onto the node after finalize, so the merge policy is editable in the UI, not just preserved. The BPMN.io canvas still shows only the picker, so it relies on the preservation above.

Unconditioned flow ids are regenerated on convert

The converter carries the Orchestra flow id only for conditioned flows (as their condition link). An unconditioned flow is drawn as a bare connection that bpmn_io ids afresh (Flow_xxx), so its stored waypoints (keyed by the Orchestra flow id) cannot be matched by id on restore, and the edge falls back to auto-routing.

Workaround: LayoutAwareBpmnIo::convert() ships each flow's {from, to} endpoints alongside the layout, and layout_restore.js matches an unconditioned flow by its endpoint pair (unique for an unconditioned flow) when its id is not found. Conditioned flows still match by id, so multiple conditioned flows between the same node pair stay distinct.

Flow labels ride only on conditioned flows

Because the owner emits a link component only for a conditioned flow, a flow label is round-tripped only when the flow has a condition. An unconditioned flow has no link to carry a name, so labelling one in config does not survive a modeler round-trip. Conditioned flows (the ones a reader most needs labelled: "Approved", "Rejected") keep their labels.

Round-trip guarantees

With the workarounds above in place, a workflow opened and saved in the BPMN.io editor preserves its node ids, its node-level structures (timers, assignments), its routing settings (join merge, split settings), its conditioned-flow labels and conditions, and its full diagram layout (node positions and every flow's waypoints). The Complete modeler edits the same model through the same owner, so the two editors are interchangeable on the same workflow.