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/splitplugin 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
FlowConditionplugin type (comparison,count,all,any) lives entirely in Orchestra. The Modeler API knows only a genericCOMPONENT_TYPE_LINK; the owner maps Orchestra's conditions onto that link type. Neithermodeler_apinorbpmn_ioknows 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.jssnaps everything back after a convert. - Node colors. Stored as
third_party_settings.modeler_api.colors(amodeler_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.jsnudges 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.