Extending Orchestra¶
Orchestra is built to be extended. This page describes the public extension
surface, the part marked @api and covered by the 1.x backward-compatibility
promise, and how to add your own behavior.
What is public¶
Classes and interfaces tagged @api form the supported contract. Within the
1.x line their method signatures will not change in a breaking way. 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.
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 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).
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\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): start instances, advance, signal, cancel and read or write process variables.WorkItemManagerInterface(orchestra.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.tenant_context): resolve the active tenant.TenantResolverInterface: a service taggedorchestra.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.audit): emit lifecycle audit events.DelegationResolverInterface(orchestra.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.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, before the start token is placed, so a subscriber can wire an external entity to the instance (it has its id) atomically with the start, with the link in place before the run. 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.TaskTimedOutEvent: a parked task'snotifytimeout fired without resuming it; turn it into a reminder or alert. See Notifications.
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.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.