Skip to content

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 RetryableException for 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 $outcome on the node's payload variable (and $reason on 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, and start() 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 tagged orchestra_engine.tenant_resolver that decides the active tenant; return a tenant machine name or NULL to defer. Also a CacheableDependencyInterface, 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", and orchestra_delegation decorates 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 calls resolve() (or resolveTo() for a form value) with its own landing as the fallback, so a task opened from a pending-actions list returns there; a list calls embed() (or the shared PendingActionLinksTrait) to add the target to an action link. Redirects are internal-only. A collect step that hands off (a webform, an off-site payment) instead set()s the target server-side, keyed by the token and the operator, and get()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 any on_started work 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_started to start(). 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: a notify timeout 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): bool is 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): AccessResultInterface is 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 with andIf().

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.