Skip to content

Distributed execution

Orchestra can drive a workflow that lives on another site. The trick is a single contract (OrchestraClientInterface) with two implementations behind it: one in-process, one over HTTP. A caller depends only on the interface, so the same code runs same-site and cross-site; the container binds whichever half the site provides.

flowchart LR
  caller["Caller<br/>(ECA action, inbox,<br/>task handler)"] --> contract{{OrchestraClientInterface}}
  contract -.->|same site| local[LocalOrchestraClient]
  contract -.->|remote site| remote[RemoteOrchestraClient]
  local --> engine[(Workflow engine)]
  remote -->|OAuth2 + HTTP| api[orchestra_server_api]
  api --> serverClient[LocalOrchestraClient] --> serverEngine[(Engine on the server)]

The contract

orchestra_api defines OrchestraClientInterface and ships LocalOrchestraClient, which wraps the engine and entity storage in process. The surface is small and transport-friendly: values cross as scalars and plain arrays, never entity objects, so a remote half can rebuild them from JSON:

Operation Meaning
startProcess(definition_id, variables) Start an instance; returns its ID.
signalToken(token_id) Resume a parked token (the completion primitive).
getInstance(instance_id) An instance with its live tokens, or NULL.
listInstances(filters) One bounded page of instance summaries, newest first.
getVariables(instance_id) The instance's variables as a name→value map.
setVariable(instance_id, name, value) Create or replace one variable.

Every operation is scoped to the current tenant: an instance, token or variable in another tenant is invisible and cannot be acted on.

The HTTP API

orchestra_server_api exposes the contract over HTTP under /orchestra-api, guarded by OAuth2 (the client-credentials grant). Its controllers are a thin transport: each delegates to a LocalOrchestraClient and serializes the result; no workflow logic lives there.

Method & path Operation
POST /orchestra-api/process/{definition_id}/start startProcess
GET /orchestra-api/instances listInstances
GET /orchestra-api/instance/{instance_id} getInstance
POST /orchestra-api/token/{token_id}/signal signalToken
GET /orchestra-api/instance/{instance_id}/variables getVariables
PUT /orchestra-api/instance/{instance_id}/variable/{name} setVariable

Two permissions gate them, so a consumer that only reads never gains the ability to drive a workflow:

Permission Endpoints
access orchestra api The three reads: listInstances, getInstance, getVariables.
drive orchestra api The three writes: startProcess, signalToken, setVariable.

A consumer's role needs both to use the whole surface. Granted only the read permission, it is answered 403 on the write endpoints, before the controller runs.

The full contract (request and response shapes, status codes, and the OAuth2 scheme) is described in OpenAPI 3.1 in openapi.yaml, with a rendered reference alongside it in api-reference.html (a ReDoc page that loads the spec).

Tenant binding

A consumer is bound to one tenant by an immutable orchestra_tenant field added to its OAuth consumer. A tagged tenant resolver (ConsumerTenantResolver) reads that binding on each request, so every API call a consumer makes is automatically scoped to its tenant: the controller never resolves a tenant itself, and a consumer cannot reach across tenants.

The remote client

orchestra_client ships RemoteOrchestraClient, which binds OrchestraClientInterface to a remote server's HTTP API. It obtains a bearer token via client-credentials (the secret held by the Key module, never in config) and calls /orchestra-api. Enabling it rebinds the contract to the remote adapter, so the same caller code now runs against the remote site. The local and remote halves are symmetric: neither the caller nor the server knows which side the other is on.

Encrypted transport

The client secret and the bearer token travel to the base URL, so it must use https. The settings form enforces this on save, and the client re-checks it at request time, so a base URL imported through configuration cannot slip past the form. Two exceptions are allowed: a loopback host (localhost, 127.0.0.1, ::1 or a *.localhost name), for a remote you run locally in development; and any host listed in a settings.php array, for a trusted internal peer reached by a name that is not resolvable as https (a Docker service name, a private-network address):

$settings['orchestra_client.allow_http_hosts'] = ['orchestra-internal', '10.0.0.5'];

The list lives in settings.php, not configuration, on purpose: allowing plain http for a host is an operator decision that a configuration import must not be able to introduce.