Workflow provider actions¶
A Moleculer module (a provider) can add its own actions to workflow. Triggers and conditions always belong to the server; a provider adds actions only. When a rule with a provider action matches, the server calls the provider's Moleculer action and records the answer in the workflow run.
The first provider is sct-mailer with mailer.send_template. A new provider — for example
sct-sms — needs only its own manifest and action; the server and CRM do not change.
provider starts / sees the server node (Moleculer $node.connected)
-> server.WorkflowRegisterProvider {provider, actions[]}
-> WorkflowManager: allowlist, node check, manifest check
-> provider actions are added to the registry and bound to triggers
trigger -> conditions -> provider action
-> params checked against the provider description
-> broker call of the provider action {context, params, idempotency_key, ...}
-> provider answers {accepted: true, id, ...} or an error
-> workflow run log
Registry lifetime¶
- The server keeps provider actions in memory only. Nothing is stored in
workflow.db; the provider is the only source of its actions. - A provider registers on every appearance of the server node: on its own start, after a server restart and after a broker reconnection, and also periodically, because the server may drop a silent provider node while the provider still sees the server. A repeated registration replaces all actions of that provider.
- When the last node of a provider disconnects (
MOL.DISCONNECT, or no heartbeat for[nats_moleculer] heartbeat_timeout_ms, default 30000), its actions leave the registry. Rules that use them are not changed. - Several nodes of one provider can be registered at once; the actions stay while at least one of them is alive. The last registration defines the actions.
Server configuration¶
[nats_moleculer]
# A module node silent for longer than this is treated as disconnected (10000..300000).
heartbeat_timeout_ms = 30000
[workflow]
# Moleculer services allowed to add their actions to workflow. Empty list: nobody.
providers = ["sct-mailer"]
# Upper bound for the timeout a provider declares for its action (1000..60000).
max_remote_timeout_ms = 15000
WorkflowRegisterProvider¶
Moleculer action server.WorkflowRegisterProvider. It exists only on the bus: it is not
a TCP, HTTP or FIX command. The caller is identified by the node that sent the request
(the sender of the Moleculer request envelope), not by request fields.
Checks¶
- The request has the right shape (see the fields below) — otherwise
400 INVALID_DATA. - The server already has the INFO of the sending node — otherwise
503 PROVIDER_NODE_UNKNOWN. The node INFO travels separately and may arrive after the first call; the provider retries. provideris listed in[workflow] providers— otherwise403 PROVIDER_NOT_ALLOWED.- The sending node runs a service named
provider— otherwise403 PROVIDER_NODE_MISMATCH. - Every action is valid (see the manifest) — otherwise
422 INVALID_MANIFEST. - Every
targetis an action of the sending node — otherwise403 PROVIDER_TARGET_NOT_FOUND. - No action code belongs to another provider — otherwise
422 ACTION_CODE_TAKEN.
The first failed check rejects the whole registration; the registry does not change.
Request fields¶
| Name | Type | Required | Description |
|---|---|---|---|
provider |
string | Yes | Moleculer service name of the provider, 1–64 characters |
actions |
array | Yes | 1–64 action descriptions |
Action description:
| Name | Type | Required | Description |
|---|---|---|---|
code |
string | Yes | Workflow code, [a-z0-9_.], up to 128 characters, must start with the provider prefix and a dot: sct-mailer → mailer., other names as they are |
target |
string | Yes | Moleculer action of the provider that the server calls, up to 128 characters |
label |
string | No | Caption for editors, up to 128 characters |
description |
string | No | Description for editors, up to 512 characters |
scope |
string | No | CRM, BO or BOTH (default) |
side_effect_level |
int | No | 0..3, same scale as server actions (default 0) |
requires_customer |
bool | No | true: the run fails with CUSTOMER_REQUIRED_FOR_ACTION when the event has no customer |
supported_payload_types |
string[] | Yes | TRADE, ACCOUNT, CUSTOMER, LEAD, KYC, NOTIFY, CASHIER, MARGIN, DESK |
only_triggers |
string[] | No | Bind only to these triggers |
exclude_triggers |
string[] | No | Never bind to these triggers |
timeout_ms |
int | Yes | How long the server waits for the answer, 1..max_remote_timeout_ms |
params |
array | No | Up to 32 parameter descriptions |
Parameter description:
| Name | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | [a-z][a-z0-9_], up to 64 characters |
type |
string | Yes | int, double, string or bool |
label |
string | No | Caption, up to 128 characters |
required |
bool | No | Default false |
default |
value of type |
No | Sent to the provider when the rule omits the parameter |
enum_values |
array | No | [{"value": <type>, "label": string}] — the only allowed values |
min, max |
number | No | Bounds, numeric parameters only |
max_length |
int | No | Positive length limit, string parameters only |
options_source |
object | No | Where editors take the choices: command, value_key, label_key (strings, required), filter_by_rule_brand (bool) |
Request example¶
{
"provider": "sct-mailer",
"actions": [
{
"code": "mailer.send_template",
"label": "Send email template",
"description": "Send the customer an email built from a mailer template",
"target": "MailerWorkflowSendTemplate",
"scope": "BOTH",
"side_effect_level": 0,
"requires_customer": true,
"supported_payload_types": ["TRADE", "CUSTOMER", "KYC", "CASHIER", "MARGIN"],
"timeout_ms": 5000,
"params": [
{
"name": "template",
"label": "Template",
"type": "int",
"required": true,
"min": 1,
"options_source": {
"command": "MailerGetTemplates",
"value_key": "id",
"label_key": "name",
"filter_by_rule_brand": true
}
},
{
"name": "fallback_template",
"label": "Fallback template",
"type": "int",
"required": false,
"min": 1,
"options_source": {
"command": "MailerGetTemplates",
"value_key": "id",
"label_key": "name",
"filter_by_rule_brand": true
}
}
]
}
]
}
Response¶
Success:
{
"provider": "sct-mailer",
"node_id": "sct-mailer-41822",
"actions": 1
}
Failure — a Moleculer error whose code is the HTTP-like status and whose name is the error code; the message repeats the code with details:
{
"name": "PROVIDER_NOT_ALLOWED",
"message": "PROVIDER_NOT_ALLOWED: sct-mailer",
"code": 403
}
| Status | Error | Meaning | Retry |
|---|---|---|---|
| 400 | INVALID_DATA |
Request shape is wrong | No |
| 403 | PROVIDER_NOT_ALLOWED |
Provider is not in [workflow] providers |
No |
| 403 | PROVIDER_NODE_MISMATCH |
The sending node does not run the provider service | No |
| 403 | PROVIDER_TARGET_NOT_FOUND |
A target is not an action of the sending node |
No |
| 422 | INVALID_MANIFEST |
An action or parameter description is invalid | No |
| 422 | ACTION_CODE_TAKEN |
Another provider owns the code | No |
| 503 | PROVIDER_NODE_UNKNOWN |
The server has not received the node INFO yet | Yes |
Binding to triggers¶
A provider action is offered on a trigger when all of the following hold:
- the trigger's payload type is in
supported_payload_types; only_triggersis empty or lists the trigger;exclude_triggersdoes not list the trigger;- the trigger is open to provider actions.
customer.password_reset_requestedis closed: its rule must hold exactly one delivery action, and its run does not keep the context.
Bound provider actions follow the server actions in
MngGetWorkflowActionsByTrigger, sorted by code. The
allowed_actions column of MngGetWorkflowTriggers lists server
actions only.
Saving rules¶
MngAddWorkflowRule, MngUpdateWorkflowRule, MngValidateWorkflowRule and
MngTestWorkflowRule check a provider action this way:
- registered provider: the action must be bound to the rule trigger
(
ACTION_NOT_ALLOWED_FOR_TRIGGER: <code>), and its params must match the description (INVALID_ACTION_PARAMS: <code>.<param> ...). Params the description does not declare are not checked and are not sent to the provider; - provider listed in the config but not registered right now: an action that the rule
already has with the same
typeandparamsis kept without checks, so the rest of the rule can be edited and its status changed; a new such action is rejected withPROVIDER_NOT_REGISTERED: <code>; - registered provider that no longer declares the code: an action already in the rule is
kept, a new one is rejected with
ACTION_NOT_REGISTERED: <code>.
Rule responses list such actions in unavailable_actions — see
MngGetWorkflowRule. After a registration the server logs a warning
for every stored rule that uses a code of this provider missing from the new manifest.
Provider call¶
When a rule matches, the action runs in its place among the rule actions; the first failed action stops the rule, as for server actions.
requires_customerwithout a customer fails withCUSTOMER_REQUIRED_FOR_ACTION: <code>.- The action must still be bound to the trigger (
ACTION_NOT_ALLOWED_FOR_TRIGGER: <code>) and its params must match the current description (INVALID_ACTION_PARAMS: ...); the provider is not called otherwise. - The server calls
targetwith the payload below and waits up totimeout_ms. - On a timeout or an unavailable provider the call is repeated once with the same
payload and the same
idempotency_key. A provider error, a concurrency limit or an invalid answer is not repeated. - The answer must be an object with
accepted: true.
The run blocks a workflow worker for the call, at most 2 × timeout_ms.
Payload¶
| Name | Type | Description |
|---|---|---|
context |
object | The event snapshot, the same JSON as input of the workflow run: rule_id, rule_code, trigger, brand, amount_usd, trade and, when present, account, group, customer, kyc_step, cashier, margin, desk. Fields are only ever added |
rule_id |
int | Rule id |
rule_code |
string | Rule code |
execution_id |
string | UUID of this rule execution; the run id does not exist yet when the provider is called |
action_index |
int | Position of the action in the rule, from 0 |
trigger |
string | Trigger code |
brand |
string | Rule brand |
customer_id |
int | Customer of the event, 0 without a customer |
idempotency_key |
string | <execution_id>:<action_index>; the provider must answer a repeated key with the same result |
params |
object | Declared params of the action, defaults applied |
__access |
object | {"brand": <rule brand>} |
{
"context": {
"rule_id": 14,
"rule_code": "wf_deposit_thanks",
"trigger": "cashier.deposit.approved",
"brand": "demo",
"amount_usd": 250.0,
"trade": { "order": 0, "login": 0, "symbol": "", "cmd": 0, "reason": 0, "volume": 0, "open_time": 0, "open_price": 0.0, "close_price": 0.0, "comment": "" },
"account": { "login": 2001, "group": "REAL_EU", "brand": "demo", "name": "Jane Doe", "email": "", "country": "DE", "customer_id": 15, "enable": 1, "leverage": 100 },
"customer": { "customer_id": 15, "brand": "demo", "country": "DE", "language": "EN", "affiliate_id": "", "campaign": "", "source_type": 1, "manager_id": 3, "assigned_manager_id": 3, "kyc_status": 0, "kyc_current_level": 1, "kyc_required_level": 1, "kyc_risk_level": 0, "risk_level": 0, "lifecycle_flags": 9, "customer_kind": 1, "created_by_import": 0 },
"cashier": { "id": 501, "customer_id": 15, "login": 2001, "type": 0, "provider": "manual", "method": "bank", "provider_payment_id": "", "amount": 250.0, "currency": "EUR", "account_currency": "EUR", "converted_amount": 250.0, "conversion_rate": 1.0, "status": 2, "source": 1, "approved_by": 3, "approved_time": 1792750000, "comment": "" }
},
"rule_id": 14,
"rule_code": "wf_deposit_thanks",
"execution_id": "3f0c1d52-7a7e-4b0f-9a55-0b8f2f6f2d11",
"action_index": 0,
"trigger": "cashier.deposit.approved",
"brand": "demo",
"customer_id": 15,
"idempotency_key": "3f0c1d52-7a7e-4b0f-9a55-0b8f2f6f2d11:0",
"params": { "template": 21, "fallback_template": 4 },
"__access": { "brand": "demo" }
}
Provider answer¶
The provider accepts the action, queues its own work and answers at once; delivery and its
retries belong to the provider. Required: accepted: true. Everything else is provider
specific and is stored in the run result. For mailer.send_template:
{
"accepted": true,
"id": 9812,
"templateId": 21,
"status": "PENDING",
"idempotent": false
}
Run result¶
A successful run with provider actions stores their answers:
{
"executed_actions": 2,
"execution_id": "3f0c1d52-7a7e-4b0f-9a55-0b8f2f6f2d11",
"provider_actions": [
{
"index": 0,
"type": "mailer.send_template",
"provider": "sct-mailer",
"response": { "accepted": true, "id": 9812, "templateId": 21, "status": "PENDING", "idempotent": false }
}
]
}
Without provider actions the result stays {"executed_actions": N}.
Run errors¶
error of the run |
Meaning |
|---|---|
PROVIDER_NOT_REGISTERED: <code> |
The provider is offline or the server has no bus |
ACTION_NOT_REGISTERED: <code> |
The provider no longer declares the code |
ACTION_NOT_ALLOWED_FOR_TRIGGER: <code> |
The new manifest no longer binds the action to this trigger |
INVALID_ACTION_PARAMS: <code>.<param> ... |
Stored params do not match the current description |
CUSTOMER_REQUIRED_FOR_ACTION: <code> |
requires_customer and the event has no customer |
REMOTE_TIMEOUT: <code> - <details> |
No answer within timeout_ms, also after the repeat |
REMOTE_UNAVAILABLE: <code> - <details> |
No node serves target, also after the repeat |
REMOTE_LIMIT: <code> - <details> |
Outbound Moleculer concurrency limit reached |
REMOTE_ERROR: <code> - <type>: <message> (code <N>) |
The provider rejected the call, for example TEMPLATE_BRAND_MISMATCH |
INVALID_REMOTE_RESPONSE: <code> |
The answer is not an object with accepted: true |
An action that failed while its provider was offline is not repeated later.
Adding a provider¶
- Add the service name to
[workflow] providersof the server. - In the module, build the manifest with codes under its prefix (
sct-sms→sms.) and send it toserver.WorkflowRegisterProvideron$node.connectedof the server node; retry on503and transport errors, log and stop on other errors. - Add the
targetaction without a REST route. It readscontext,paramsandidempotency_key, performs or queues the work through the module's existing code and answers{accepted: true, ...}, or throws a Moleculer error with a code and type.
Editors need no change: the action, its params and options_source choices come from
MngGetWorkflowActionsByTrigger.