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¶
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.
Composite conditions (All / Any)¶
A flow carries a single condition, but all and any are composite
conditions: each holds an ordered list of child conditions in its
settings.conditions, and a child may itself be a leaf (comparison, count)
or another composite, so a flow can require any tree of conditions, nested to
any depth. The engine evaluates the tree in CompositeConditionBase, and the
config schema (orchestra.flow_condition.composite) validates it recursively.
The Complete modeler (orchestra_cm) builds this tree with a recursive
editor: choose All or Any on a flow and it shows an Add sub-condition button;
each child row is a condition picker plus that condition's own settings, and
picking All or Any for a child nests another editor inside it. Remove drops a
child. The tree is assembled back into settings.conditions on save.
BPMN.io has no visual editor for the tree, but it never loses it. The
composite plugin exposes its children as a single JSON conditions field, so
the flat camunda:field serializer round-trips the whole tree as one string,
and Orchestra::ownerComponentDefaultConfig() declares the conditions key as
an array so bpmn_io rehydrates it on import. A composite built in the Complete
modeler therefore survives a later save on the BPMN.io canvas unchanged.
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.
- Seeded start event. A workflow has exactly one start node, so a new
model's canvas opens with it already drawn (
n_start, carrying thepluginidproperty the parser needs and the element template the property panel reads), rather than making the author's first act pure boilerplate. It is seeded only for a model that is new: an existing workflow's own start arrives with its components, and a second one would be refused 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;flow_labels: external flow-label bounds keyed by flow id;node_labels: 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 labeling one in config does not survive a
modeler round-trip. Conditioned flows (the ones a reader most needs labeled:
"Approved", "Rejected") keep their labels.
A task config form is built without a request¶
To validate a workflow on save, the Modeler API builds each task's
buildConfigurationForm() outside a request: a bare FormState with no
user input, and (at build time, before FormBuilder processes the form) no
#parents. A task type's config form must not depend on either being
populated. This bites a task type that embeds another plugin's settings as a
nested subform (the payment and settle tasks embed a payable resolver; an
interaction task embeds an interaction):
- Read raw input defensively.
$form_state->getUserInput()isNULLoutside a request. Default it to[]in a variable beforeNestedArray::getValue(), which takes its subject by reference. - Do not bake a build-time
#parentsonto the nested subform. Because$form['#parents']is empty when the modeler builds the form, a subform whose#parentsis computed from it then loses the parent's prefix; at submit it no longer sits under the parent form and the nestedSubformStatethrows "the subform is not contained by the given parent form." Let FormBuilder derive the subform's#parentsfrom its position in the tree instead. The interaction settings subform is the reference pattern; a subform placed throughInnerPluginSettingsTraitmust drop the seeded#parentsbefore it is returned to the host form.
A nested (structured) setting must also declare an array default in
defaultConfiguration(). The owner JSON-encodes array-valued config keys to a
flat string on export and decodes them on import (getComponentConfig() and
addComponent()), so a structured setting round-trips through the BPMN blob
only if the task type declares it as an array. A setting a task type stores as
its own opaque JSON string keeps a scalar default and owns its own
(de)serialization.
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.
Deploying a workflow from config¶
A workflow shipped or deployed as config (an installed YAML, drush cim, an
->save() from config/install) carries whatever modeler_id the config
records, alongside a stored BPMN blob. If that modeler_id is bpmn_io and the
blob is not a faithful export of the workflow's nodes (for example a node
was added to the structured config but its blob element was authored by hand
without every field), BPMN.io opens the stale blob and the next save writes it
back, silently dropping the fields the blob omits. The structured nodes, not the
blob, are the source of truth.
Record modeler_id: orchestra_cm on a config-deployed workflow. BPMN.io then
re-derives the diagram from the nodes on first open (a save by another modeler
leaves the blob untouched, see above), so the blob cannot shadow the nodes;
after one BPMN.io save it is a faithful export again and modeler_id returns to
bpmn_io. This keeps a hand-maintained or freshly-extended seed from losing
config the moment it is opened in the canvas editor.