Extending Orchestra¶
Orchestra is built to be extended. This page describes the public extension
surface, the part marked @api, and how to add your own behavior.
What is public¶
Classes and interfaces tagged @api mark the intended public contract. It
freezes at 1.0: from the first stable release an @api method signature
will not change in a breaking way. Through the alphas it is still being
settled, so build against it, expect to follow a change or two, and say so in
the issue queue when one costs you something.
Everything else, in particular the concrete service implementations tagged
@internal (WorkflowEngine, WorkItemManager, OrchestraAudit,
AttachmentManager), is mechanics that may change between minor releases:
depend on the matching interface, not the implementation.
Plugin types¶
Each plugin type is a discovery directory, an attribute and a base class. To add
one, drop a class in your module's src/Plugin/<Type>/ directory, give it the
attribute, and extend the base class (or implement the interface directly).
| Plugin type | Directory | Attribute | Base class / interface |
|---|---|---|---|
| Task type | Plugin/TaskType |
#[TaskType] |
TaskTypeBase / TaskTypeInterface |
| Gateway | Plugin/Gateway |
#[Gateway] |
GatewayBase / GatewayInterface |
| Flow condition | Plugin/FlowCondition |
#[FlowCondition] |
FlowConditionBase / FlowConditionInterface |
| Split | Plugin/Split |
#[Split] |
SplitBase / SplitInterface |
| Join | Plugin/Join |
#[Join] |
JoinBase / JoinInterface |
| Timeout action | Plugin/TimeoutAction |
#[TimeoutAction] |
TimeoutActionBase / TimeoutActionInterface |
| Node feature | Plugin/NodeFeature |
#[NodeFeature] |
NodeFeatureBase / NodeFeatureInterface |
| Audience | Plugin/Audience |
#[Audience] |
AudienceBase / AudienceInterface |
| Variable provider | Plugin/VariableProvider |
#[VariableProvider] |
VariableProviderBase / VariableProviderInterface |
| Deadline provider | Plugin/DeadlineProvider |
#[DeadlineProvider] |
DeadlineProviderBase / DeadlineProviderInterface |
A variable provider computes process variables for a run, and contributes them wherever a variables node selects it.
A deadline provider says when a parked task's wait runs out, and every timeout names one. Ship your own when the deadline belongs to a live subject the run does not own: it is asked as the task parks, so it reports that subject as it stands rather than as some earlier step recorded it.
Refusing a setting¶
A plugin that validates its own settings is handed a SubformState by whatever
host embeds it, and how it reports a refusal decides whether the author ever
finds the field. Set the error on the element, never by name.
$form_state->setError($form['mode'], $message); // finds the field
$form_state->setErrorByName('mode', $message); // reaches nothing
An error is matched to its element by that element's #parents, the path its
value was submitted under. SubformState::setErrorByName() builds the name out
of #array_parents instead, the path the subform is rendered at, and in
Orchestra the two differ: the classic modeler renders every node beneath a
structure wrapper that the submitted path does not carry. So a name-built
error belongs to no element on the page. The control keeps its normal styling
and gets no aria-invalid, and the collapsed section holding it stays shut,
because the modeler opens a section by matching that same key. The author is
shown the message with nothing on screen to correct.
FormState::setError() reads #parents off the element you hand it, so it is
right however deeply a host nests the subform, and it needs to know nothing
about that host. Reading #parents there is safe: validation runs on the built
form, so the form builder has already stamped them.
Reading the value is the other half, and #parents is the wrong answer to it.
A SubformState's getValue() is already relative to the subform, so it takes
the field's own name:
$form_state->getValue('mode'); // the submitted value
$form_state->getValue($form['mode']['#parents']); // NULL, under a SubformState
#parents is the path from the top of the whole form, and the subform state
has already cut that prefix off everything it holds. Handing it back asks for
that whole path a second time, inside the slice it has just made, so it matches
nothing and answers NULL. A validator then reads an empty field - and an empty
field is what nearly every one of these refusals deliberately lets through,
since empty means "unset" rather than "wrong". So the refusal never fires, on
any value, and nothing on the page says so.
The second line is worth knowing precisely because it is not always wrong. Hand
a plugin a plain FormState with the values set at the root, which is what a
test that builds its own is doing, and the two paths are the same one, so the
refusal fires and the test passes. It is only under the SubformState that
every real host passes that they differ. So a validator is worth testing
through the host that embeds it - in Orchestra, by saving a workflow - and not
by calling it with a form state of your own making.
Reading a node¶
Every surface that is handed a node - a timeout action, a deadline provider, a
node feature - receives a WorkflowNode, and a definition hands them out
through getNode() and getNodes():
$node = $definition->getNode('n_review');
$node->getId(); // 'n_review'
$node->getLabel(); // its translated label
$node->getTaskType(); // the task type plugin id
$node->getConfig(); // the task type's own settings
$node->getJoin(); // the join slot, [] on the engine default
$node->getFeature('timers'); // one feature's settings, [] when unset
$node->hasFeature('timers'); // told apart from an author's empty list
getNode() answers NULL where the definition has no node by that id, which a
run that outlived an edit to its workflow reaches: the token still names the
node it is standing on after the node itself is gone. Read through ?-> and
say what happens with no node, rather than assuming there is one.
It is a read view over the node as stored, so toArray() gives back exactly
what was stored, unchanged. That matters: a version's executable hash is taken
over the definition as it was written, so anything that reorders keys or turns
an absent key into an empty one cuts a new version of every workflow.
Walking a definition by name and reading nothing off the nodes wants
getNodeIds(), which saves building a view of each one to keep the keys.
Authoring is the other direction. A surface that builds a node by merging
submitted values, and hands the whole map back through set('nodes', ...),
works in arrays and wants getRawNodes(); toArray() is the seam for a single
node already in hand. That one is on WorkflowInterface, the editable
workflow, rather than on the definition contract both holders share: writing
and encoding only ever happen from the editable side, so an immutable version
snapshot does not offer the door at all.
A node feature of your own, and the key it owns¶
A node feature edits one key on a node and reads it back at runtime, and it says
which key through getConfigKey(). Which nodes it applies to, its
#[NodeFeature] attribute declares by what their task type implements, never by
a task type's ID: appliesTo lists the interfaces a task type implements one of
(ParkingInterface for the steps that wait, AssignableInterface,
NotifyingTaskTypeInterface, TaskTypeInterface for every task type), and
excludes the ones that leave it out (StartTaskTypeInterface). A feature about
the wait at a join applies through the node's join instead: joins lists the
interfaces its join plugin implements one of (FireModeJoinInterface for a join
waiting for branches, a gateway's preset join included), as the join timeout
does. A task type or a join of yours gets the feature by implementing the
interface, and a feature of yours can ask for an interface of its own;
NodeFeatureManager::getFeaturesForNode() answers from the definitions, without
instantiating the task type or the join. Those keys
live together under the node's features, which Orchestra types by the key that
owns it:
features:
type: sequence
label: 'Node feature settings'
sequence:
type: orchestra_engine.node_feature.[%key]
So declare the shape of your key in your own module's schema, beside the plugin that reads it, named for the key:
# my_module/config/schema/my_module.schema.yml
orchestra_engine.node_feature.my_feature:
type: mapping
label: 'My feature'
mapping:
mode:
type: string
label: 'Mode'
Nothing in Orchestra changes, which is the point: the container is the kernel's
and each entry is its feature's. A workflow carrying a key nobody declares is
still valid - a wildcard catches it - so a missing declaration costs you the
validation rather than breaking the site, and NodeSettingSchemaTest is what
reports it.
The workflow entity is FullyValidatable, so a key you type as a mapping has
to name every setting it carries; a free-form map wants type: ignore, the way
the wildcard does.
If your settings name a notification profile or audience plugins, implement
NodeFeatureReferencesInterface as well: getNotificationProfilePath() says
where below your key the profile id is stored, and getAudiencePluginIds()
returns the audience plugin ids a stored value names. The workflow then depends
on that profile and on the modules providing those audiences, as it does for
the engine's own features, so neither can be removed while a node names it.
If your settings hold a text written with a text format (a value and a format,
the shape of the text_format config schema type), implement
NodeFeatureFormattedTextInterface as well: getFormattedTextPaths() says
where below your key such texts are stored. The workflow then depends on their
text formats, so config import creates them first, and a format removed from
the site leaves the text in the site's fallback format instead of deleting the
workflow. The External interaction's Page description works this way.
A canvas modeler (bpmn_io) edits a node in one panel of flat fields and cannot
render your feature's editor. To be editable there too, offer fields of your
own: buildFlatFields() returns them, keyed by names no task type uses for a
setting, getFlatValues() fills them from a node, and
getValueFromFlatValues() turns what the modeler saved back into your key's
value, keeping what the fields cannot express. The modeler adds them to the
panel of every task type your feature applies to; no task type declares them,
and none is stored in the node's task settings. The Assignments feature works
this way, with one field per audience that is one list.
A plugin family of your own, and the dependency it creates¶
A workflow records the modules providing the plugins it names, so a config import orders them first and the uninstall form refuses to strand a running workflow rather than let it dead-letter at the next advance. Orchestra walks the families in the table above, because its own configuration schema declares every one of them.
Add a family of your own and it is keyed by config of your own, which Orchestra knows nothing about and deliberately does not guess at: an optional module's vocabulary has no business in the kernel, and the next family would need the same again. Answer for it instead:
#[Hook('orchestra_workflow_module_dependencies')]
public function workflowModuleDependencies(WorkflowInterface $workflow): array {
$modules = [];
foreach ($workflow->getNodes() as $node) {
$id = (string) ($node->getConfig()['my_family'] ?? '');
if ($id !== '' && $this->myFamilyManager->hasDefinition($id)) {
$modules[] = $this->myFamilyManager->getDefinition($id)['provider'];
}
}
return $modules;
}
Orchestra sorts the names, drops duplicates and drops its own, so returning one twice costs nothing. It is called when a workflow calculates its dependencies, which is on save and on a config import, never on a run path.
orchestra_payment implements it for the payable resolver a payment step
prices with, which a node names in two places because there are two ways to
take a payment: the settle task keeps it among the node's config, the payment
interaction inside the interaction's settings. orchestra_payment_example
ships a resolver of its own, so a workflow pricing through it is a live case
rather than a hypothetical one.
A timeout action that resumes a task with an outcome of its own declares it, so the editor can tell an author what to route on:
#[TimeoutAction(
id: 'my_action',
label: new TranslatableMarkup('...'),
resumesWith: ['expired'],
)]
Declare nothing when the action leaves the task parked (a reminder, a released
claim), or when the outcome is the author's own setting, as resume's is. Like
provides, this is documentation rather than a contract: nothing rejects an
action that resumes with an outcome it did not declare. Getting it wrong is
quiet, which is the reason to declare it at all - a flow condition naming an
outcome nothing announces is not an error, it is a branch never taken, and it
surfaces the first time the timeout fires.
A flow condition answers evaluate($variables), and summary($variables): a
one-line diagnostic of what it tested and the value it read, which the engine
puts on the incident it raises when a branch
dead-ends. Extending
FlowConditionBase gives you a generic rendering (the settings as they stand),
but a condition that reads process variables should override summary() and
name the value it saw, since that is what identifies the cause. formatValue()
renders one value unambiguously (NULL, TRUE, a quoted string). It is only
called to explain a failure, never while routing.
An audience resolves who a node notifies (getRecipients()). An audience that can
also staff a user task additionally implements AssignmentInterface (extending
AudienceInterface with getCandidates()/getViewerTokens()) and extends
AssignmentBase; a pure notification audience (a fixed external email) extends
AudienceBase alone and never appears in the task assignment editor.
getCandidates() returns what the audience resolved to and nothing else. When
every audience a node names comes back empty, the step asked for a restriction
and did not get one: rather than offer it to every viewer, the engine halts the
token before it parks and raises an incident, so no task is created and an
operator can fix the audience and retry.
An assignment that restricts nobody returns NULL from getCandidates(), which
is what "open to everyone" is called everywhere else, including how such a step
reads back from storage. That is the only way a step is open to all: a human
node naming no audience is refused when the model is saved.
A task's candidates are minted once and then frozen, so a third method,
getUnmatchedCandidates(), is how a dimension says that its own tokens have
since stopped naming anybody: given a step's stored candidates it returns those
it minted that no live account carries, ignoring the tokens of every other
dimension. The cron sweep asks each assignment and reports a step whose every
candidate came back unmatched (see
a step nobody can take). A plugin whose
membership cannot be enumerated (an external directory, a rule evaluated per
account) leaves the AssignmentBase implementation alone, which answers
nothing: the sweep then has a token it cannot speak for, and leaves the whole
step be. A plugin that does answer starts from getTokenValues() on the same
base, which picks that plugin's own tokens out of the set and hands back what
they name.
The same shape answers the other question a frozen candidate set raises: what to
call it. getCandidateLabels() is given a step's stored candidates and returns
a label for each of its own tokens, keyed by the token, so a pooled row reads
"Offered to: Reviewer, dana" rather than printing the addresses the matching
compares. A token no installed dimension answers for keeps its own text, and a
token naming an account that has gone is left out rather than named something
untrue. getCandidateLabelCacheTags() says what that naming read - a role's
config tag, an account's own tag - so a surface that prints those names merges
them into its own cacheability and a rename reaches every cached list; without
them a renamed role keeps its old word, because naming reads configuration that
writes nothing to the task. AssignmentBase answers nothing for both, which is
the right answer for a directory that cannot be enumerated.
A task type can opt into extra capabilities by implementing the matching
capability interface: SubprocessTaskInterface (launch a child process),
ParkingInterface (park the token and wait), PluginSequenceFeatureInterface
(compose node features), WorkItemPresentationInterface (inbox presentation),
OrchestraContextAwareInterface (receive the running token and instance).
Three marker interfaces say what a type is for, and the node features, the
editors and the modelers read them rather than a plugin ID:
StartTaskTypeInterface (it starts a workflow: the editors seed a new one with
it, allow one per workflow and draw it as the start event),
EndTaskTypeInterface (it ends a branch, so a message on it is the final one)
and NotifyingTaskTypeInterface (it sends a notification, so it carries an
audience, a profile and values); the task type manager tells them apart
like every other capability (see below).
The other plugin types work the same way: Orchestra never names a plugin of its
own where a plugin of yours could take its place, and asks the plugin instead.
Every Orchestra plugin manager implements CapabilityPluginManagerInterface:
isPluginImplementing($plugin_id, $interface) and
getPluginIdsImplementing($interface) answer from the definitions, without
instantiating a plugin, which is how the engine, the editors and the node
features ask what a plugin does. A task type whose nodes run a workflow inside
their parent's instance (the subprocess task in embedded mode) implements
EmbeddingTaskTypeInterface, and the engine, the versioning and the readers ask
it through EmbeddedWorkflowResolver. A timeout action says which waits it can
act on: a task's (TaskTimeoutActionInterface), a join's for its branches
(JoinTimeoutActionInterface), or both. A join waiting for the branches still
on their way, as the matching join does, implements MatchingJoinInterface; one
that can require a set number of branches, FixedCountJoinInterface. A split
taking its flows in order, so their order is a priority, implements
PrioritySplitInterface. A split creating one branch per item implements
ItemSplitInterface: it lists its items (listItems(), keyed by item) and
names the variable each branch receives its item in (getItemVariableName());
the engine does the rest (see One branch per item).
One whose items are the members of the node's audience implements
AudienceSplitInterface, which brings the node its Audience section. A flow
condition that one variable set to one value makes hold implements
OutcomeConditionInterface, and the instance page offers its flow as an outcome
to pick. An audience whose settings are one list (the users, the roles, the
groups) implements ListAudienceInterface, naming the key of that list, and the
Assignments feature offers a canvas modeler a field for it. An interaction that
sends the link holder to a page it does not draw implements
RedirectingInteractionInterface (in orchestra_interaction), so a webform
submission may complete its step. An interaction that can tell whether what a
step is resumed with is its own implements ResumeContextCheckInterface (in
orchestra_interaction): the webform interaction accepts only its own webform,
or the submission it shows. One that does not implement it refuses a resume
that carries a context. An interaction that shows messages with its step,
such as an earlier reviewer's comment, implements MessageDefinitionsInterface
(in orchestra_interaction): each definition names a variable, a label and a
severity, and ContextMessages reads them against the instance, on this
site's pages and for a site showing the step away from them. One that does not
implement it shows no messages. A new way of handing a step out to a person, beside a
link, a task and an operation, registers a resume handle scheme: a service
implementing ResumeHandleResolverInterface, tagged
orchestra_engine.resume_handle_scheme.
Three traits carry settings a node type would otherwise write out again:
CompletionConfigTrait (the payload variable and the scope it is written in),
SubprocessMappingTrait (the input and output variable maps, and what each
ending gives the parent) and RelaunchConfigTrait (how many times a failed
child is tried again, and after how long). A node that runs a child process
uses the last two whether the child runs here or on another Orchestra, so the
two node types that do cannot drift apart on what a map or a budget means.
Both also implement SubprocessEndingRulesInterface, the rules those traits
answer, and what a child's ending means (resume the parent, launch a fresh
child, or halt the branch) is decided once, by SubprocessEnding::decide(),
for either kind of child.
Signaling failure from a task¶
When a task's execute() throws, the engine classifies the failure by the
exception it gets (see Incidents). The markers live in
Drupal\orchestra_engine\Exception:
- Throw a
RetryableExceptionfor a transient failure the engine should retry per the node's retry policy, dead-lettering to an incident only once the attempts are spent. - Throw an
OutcomeException($outcome, $reason)for a definite outcome the workflow should route on: the engine records$outcomeon the node's payload variable (and$reasonon its message variable, if configured) and advances the token, so an outgoing flow branches on it. Neither retried nor an incident. - Throw a
FatalException, or let any other exception propagate, for a failure a retry cannot fix. An unmarked exception is treated as fatal: the engine raises an incident at once. So only declare a failure retryable when a later, identical attempt genuinely might clear it.
Service contracts¶
These interfaces are safe to type-hint, decorate or mock:
WorkflowEngineInterface(orchestra_engine.engine): start instances, advance, signal, cancel and read or write process variables.executeInBatch()runs a pass over many runs as one batch, which is what a cron sweep of your own wants: operations inside it leave their work on the queue for the worker instead of draining each run to its end in the cron request. The engine's own sweeps use it.canStart()answers read-only whether a start would be accepted, so a surface offering something whose submission would start a run can say so before taking the answers rather than after; it judges the live configuration, andstart()stays the enforcement.WorkItemManagerInterface(orchestra_engine.work_item_manager): create, claim, reassign and complete human tasks, and decide who may act on one (canAct()/checkActionAccess(), see Asking whether someone may act).isAssigneeOrStandIn()answers the narrower question a surface asks to decide which of a holder's actions to offer.TenantContextInterface(orchestra_engine.tenant_context): resolve the active tenant.TenantResolverInterface: a service taggedorchestra_engine.tenant_resolverthat decides the active tenant; return a tenant machine name orNULLto defer. Also aCacheableDependencyInterface, because a resolver is the only thing that knows what it read to reach its answer (a host, an OAuth consumer), and anything caching a tenant-scoped decision has to inherit that.OrchestraAuditInterface(orchestra_engine.audit): emit lifecycle audit events.DelegationResolverInterface(orchestra_engine.delegation_resolver): who is standing in for whom. The shipped implementation answers "nobody", andorchestra_delegationdecorates it with stored delegations; a site whose absences already live in an HR system, an LDAP attribute or a group module can decorate it instead and keep its own source of truth, with no change to any task, surface or query. An implementation owes the contract two rules: cover does not chain (one hop), and every answer is per tenant. See Delegation.AttachmentManagerInterface(orchestra_content.attachment_manager): bind content entities to a running instance.OrchestraReturn(orchestra_engine.return): the seam for the operator return-to-list behavior. A completion surface callsresolve()(orresolveTo()for a form value) with its own landing as the fallback, so a task opened from a pending-actions list returns there; a list callsembed()(or the sharedPendingActionLinksTrait) to add the target to an action link. Redirects are internal-only. A collect step that hands off (a webform, an off-site payment) insteadset()s the target server-side, keyed by the token and the operator, andget()s it when the step resumes.
The entity contracts (ProcessInstanceInterface, TokenInterface,
VariableInterface, WorkflowInterface, TenantInterface,
WorkItemInterface, AttachmentInterface) are likewise part of the public
read API.
Events¶
The engine dispatches typed domain events you can subscribe to:
-
InstanceStartedEvent: an instance has been created and its variables seeded, but it has not run yet. It fires inside the start transaction, after anyon_startedwork the starting caller handed in and before the start token is placed, so a subscriber sees a fully wired instance and can react to anyone's start atomically with it. A subscriber that throws rolls the whole start back, so keep them quick. (A plain entity insert hook is not enough: it fires before the variables are seeded, so a subscriber could not yet tell which entity the run is for.) The matching reverse link is the instance id, the integer primary key, which an external table can hold as a foreign key for a Views relationship.To bind something to a run you are starting yourself, do not subscribe: pass
on_startedtostart(). The engine calls it at the same point, with the instance it just created, so the binding is committed with the start and is in place before the first node runs. A subscriber cannot tell your start from one that a hook made while yours was still creating its instance (the inner start announces itself first), so it is the wrong tool for work addressed to one particular start. Binding a content entity to the run it is about is the worked example: see Content-bound tasks. -TaskTimedOutEvent: anotifytimeout fired without ending the wait, on a parked task or on a join waiting for its branches (the token is then one waiting at the join, with no task); turn it into a reminder or alert. See Notifications. -StateTransitionEvent: a token, an instance or an incident moved from one state to another. The engine flips a contended state with a guarded write rather than an entity save, so exactly one caller wins the race; that write fires no save hook, and this is how the transition says it happened. It is dispatched only by the caller that performed the flip, so it fires once per transition and never for an attempt that found the row already moved on. The event carries the entity (already holding the new state), the state it left, the state it reached, and the reason the caller named, if any. Subscribe to it to react to a branch ending, a run ending or an incident being taken up: the module's own task side does, to cancel the task of a step that halted. Like the other engine events it is dispatched inside the caller's transaction, so a subscriber that throws rolls the transition back.
Asking whether someone may act¶
Two forms of one decision, and which you want depends on what you do with the answer:
canAct($task, $account): boolis the verdict, and allocates nothing. Use it where the answer is consumed and thrown away, above all per row in a list.checkActionAccess($task, $account): AccessResultInterfaceis the same verdict carrying its reason and its cacheability. Use it where it is merged into something cached: a route access check, or a result you combine withandIf().
The distinction matters because the verdict moves for reasons the calling code
cannot see. It reads the task, the account's roles (a role:editor audience
token is minted from getRoles()), the resolved tenant, and any standing cover,
which is dated: it begins and lapses on the clock with nothing written to the
database when it does.
So a surface that renders from canAct() must declare those dependencies
itself. The inbox columns do it once per display rather than once per row, by
declaring what AssignmentMatcher reports:
public function getCacheTags(): array {
return Cache::mergeTags(
$this->matcher->getCacheTags(),
['user:' . (int) $this->currentUser->id()],
);
}
public function getCacheMaxAge(): int {
return $this->matcher->getCacheMaxAge();
}
The account's own tag is not optional: a per-user cache context partitions by
uid, so it never fires when that uid's roles change. And the max-age is what
makes cover appearing at 09:00 or lapsing at 18:00 reach the screen, since no
row and no config changes at those moments. Returning Cache::PERMANENT there
leaves a stand-in looking at rows that offer them nothing.
Resolving the tenant¶
A multi-tenant site decides the active tenant through resolvers. Tag a service
orchestra_engine.tenant_resolver, implement TenantResolverInterface, and
return the tenant machine name for the request, or NULL to let the next
resolver decide. The first non-null answer wins; with none the default tenant
applies. The orchestra_domain submodule ships a resolver that maps the current
domain to a tenant.
A resolver also declares its own cacheability, so a consumer never has to guess
that installing a domain connector made every tenant-scoped answer vary by host.
TenantContextInterface aggregates its resolvers, which is what an access check
or a rendered list depends on. Declare the contexts your answer varies by and
the tags that change it; return Cache::PERMANENT and no tags if you read
nothing request-specific.