Skip to content

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

  1. The request has the right shape (see the fields below) — otherwise 400 INVALID_DATA.
  2. 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.
  3. provider is listed in [workflow] providers — otherwise 403 PROVIDER_NOT_ALLOWED.
  4. The sending node runs a service named provider — otherwise 403 PROVIDER_NODE_MISMATCH.
  5. Every action is valid (see the manifest) — otherwise 422 INVALID_MANIFEST.
  6. Every target is an action of the sending node — otherwise 403 PROVIDER_TARGET_NOT_FOUND.
  7. 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_triggers is empty or lists the trigger;
  • exclude_triggers does not list the trigger;
  • the trigger is open to provider actions. customer.password_reset_requested is 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 type and params is kept without checks, so the rest of the rule can be edited and its status changed; a new such action is rejected with PROVIDER_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.

  1. requires_customer without a customer fails with CUSTOMER_REQUIRED_FOR_ACTION: <code>.
  2. 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.
  3. The server calls target with the payload below and waits up to timeout_ms.
  4. 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.
  5. 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

  1. Add the service name to [workflow] providers of the server.
  2. In the module, build the manifest with codes under its prefix (sct-sms → sms.) and send it to server.WorkflowRegisterProvider on $node.connected of the server node; retry on 503 and transport errors, log and stop on other errors.
  3. Add the target action without a REST route. It reads context, params and idempotency_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.