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.