Skip to content

Workflow Engine Developer Guide

Ed Mozley edited this page Oct 25, 2026 · 5 revisions

Workflow engine - Developer Guide

How the Workflow module is built: which file does what, how an event gets from a host module into the engine, how a run is matched, checked, executed and logged, how webhooks leave the building, and how the canvas editor stores a workflow. For what a workflow is - triggers, conditions, chaining, dry run, merge codes - read Workflows first; this page links to it rather than repeating it.

In plain words

WorkflowEngine (workflow/includes/engine.php) is one static class. Host modules call WorkflowEngine::dispatch($event, $payload) after they write something. The engine loads every active workflow whose trigger_event is that event, adds readable names and links to the payload, writes a running row to workflow_executions, checks the conditions in order, runs the actions in order through a switch, and writes the outcome back to the same row. Nothing it does can throw back into the host module.

Two things are not done in the request: a webhook is queued to webhook_deliveries and sent later by cron/webhook_deliveries.php, and time-based triggers are dispatched by crons that go looking, with a fire-once ledger in front of dispatch().

The editor (assets/js/workflow-editor.js) is a canvas of nodes, but what it saves is the same flat shape the engine reads: one trigger, an ordered list of conditions, an ordered list of actions. The order is each node's height on the canvas.

The files

File What it does
workflow/includes/engine.php WorkflowEngine: the catalogues (triggers, fields, variables, operators, actions, lookups), dispatch(), manualFire(), runActionList(), the run loop, condition evaluation, the 16 action_* handlers (three share action_ticket_field()), payload enrichment, webhook request building, the company guards
includes/workflow_tenancy.php Workflows and companies: workflowEventTenantId(), the list / run filters, analystCanReadWorkflow(), analystCanWriteWorkflowFor(), analystCanReadWorkflowRun() - see Companies
workflow/includes/templates.php WorkflowTemplates: the starter recipes, catalogue(), get(), and resolve() which turns $lookup / $configure markers into this install's values (Starter templates)
workflow/includes/header.php The module's header bar
workflow/index.php The list page and the New-from-template gallery (list.php, delete.php, templates.php, create_from_template.php)
workflow/editor.php The canvas page. Exports every engine catalogue to window.WF_* globals, then loads workflow-editor.js
workflow/executions.php The cross-workflow run log; its script is assets/js/workflow-executions.js
workflow/settings/index.php Module settings: the AI panel (renderAiSettingsPanel('workflow_ai') from includes/ai_settings_panel.php) and the webhook message formats
workflow/help.php, help-webhooks.php, help-ssl.php In-app help
assets/js/workflow-editor.js The canvas: node model, drag and snap, connectors, detail panel, per-arg forms, merge-code picker, Send test, Test fire / Dry run, AI co-author
assets/css/workflow.css Canvas, nodes, detail panel, status pip, AI modal
includes/webhook_delivery.php The delivery queue: encrypt / decrypt, webhookEnqueue(), webhookHttpSend(), webhookAttemptDelivery(), webhookRunQueue(), backoff, replay, payload retention
includes/workflow_scheduled.php Time-based triggers: workflowEmitOnce() (the fire-once ledger), the contract and warranty detectors, workflowScheduledRun(), workflowPruneEmissions()
cron/webhook_deliveries.php The delivery worker (cron entry point)
cron/workflow_scheduled.php The time-based cron entry point (contracts, warranties, planned maintenance, projects)
includes/ticket_events.php ticketDispatchCreated() - the one place ticket.created is built and fired, whichever way the ticket arrived
includes/functions.php wf_emit() - the self-safe CRUD emitter for settings endpoints
system/webhooks/index.php The webhook delivery log and dashboard (System β†’ Webhooks)
api/v1/resources/workflows.php The REST resource - see REST API: Workflow

api/workflow/

Endpoint What it does
list.php, get.php List workflows; one workflow with conditions / actions decoded, webhook url / secret decrypted, and its last 20 executions
save.php Create or update. Checks the name, that trigger_event is in availableTriggers() and every action type is in availableActions(), then webhookEncryptActions() and stores both lists as JSON
delete.php Delete a workflow. Its execution rows stay (no cascade)
fire.php Test fire and Dry run: {id, payload?, dry_run?} β†’ WorkflowEngine::manualFire()
webhook_test.php The Send test button - builds the request with buildWebhookRequest() and sends it synchronously with webhookHttpSend(); never queued, never logged as a delivery
executions.php, execution_detail.php The run log and one run in full (step log + payload snapshot)
deliveries.php, deliveries_replay.php, save_payload_retention.php The delivery log, Replay (webhookReplay()), and how long sent bodies are kept
formats.php, save_format.php, delete_format.php Webhook message formats (webhook_message_formats)
templates.php, create_from_template.php The recipe gallery; a clone is always created inactive
_ai_helpers.php, ai_compose.php, save_ai_settings.php, test_ai_key.php The AI co-author and its settings (AI co-author)

Every endpoint checks $_SESSION['analyst_id']. Most then call requireModuleAccessJson('workflow'); the exceptions are deliveries.php (requireModuleAccessJson('system') - the log lives under System β†’ Webhooks) and save_payload_retention.php (requireAdminJson()). The writers call csrfRequireWriteMethod() first (CSRF protection).

The tables

All in includes/db_verify_schema.php and database/freeitsm.sql.

Table What it holds
workflows name, description, trigger_event, conditions (TEXT, JSON), actions (TEXT, JSON), is_active, created_by, last_run_datetime, last_run_status, run_count, is_demo, tenant_id (4.1.0 - see Companies)
workflow_executions One row per run: workflow_id (NULL for a form's own list, or after the workflow is deleted), workflow_name (snapshot), trigger_event, trigger_payload (the enriched payload, JSON), status, is_dry_run, started_datetime, finished_datetime, step_log (JSON), error_message, tenant_id (the event's company, 4.1.0)
webhook_deliveries The queue and the delivery log: workflow_id, execution_id, preset, url (encrypted), method, request_headers, request_body, payload_purged, status, attempts, max_attempts (default 6), next_attempt_at, last_status_code, last_error, response_snippet, delivered_datetime
webhook_message_formats Admin-editable chat formats: format_key, label, body_template, url_pattern, markdown_hint, is_builtin, is_active, display_order
workflow_scheduled_emissions The fire-once ledger: trigger_event, entity_key, fingerprint, emitted_datetime, with the unique key uq_wse_once over the first three

Execution status values written by the engine: running, success, failed, skipped, aborted. Delivery status values: pending, delivering, delivered, failed, dead.

Forms keep their own action lists in forms.submission_actions (JSON {"submitted":[…],"approved":[…],"rejected":[…]}) - the same {type, args} shape, run by the same engine (Form submission actions).

How an event reaches the engine

There are four ways in, and they all end at dispatch():

Caller Used by
WorkflowEngine::dispatch($event, $payload) The services under includes/services/ (tickets, tasks, changes, problems, assets, CMDB, knowledge, contracts, calendar, service status, morning checks, forms, domains…), plus includes/ticket_events.php, includes/catalogue_approvals.php, includes/integrations/integrations.php (tracker events), includes/intune.php, includes/domains/alerts.php / watch.php, includes/projects/alerts.php, api/tickets/check_mailbox_email.php, api/self-service/reply_ticket.php
WorkflowEngine::emitCrud($entityKey, $action, $id, $name) Fires <entity>.created/.updated/.deleted with {<entity>: {id, name}}. The entities are the list in crudEntities()
wf_emit($entity, $action, $id, $name) The settings endpoints. Lazily requires the engine and swallows any Throwable, so it can never break the save it follows
workflowEmitOnce(...) Time-based triggers - see below

dispatch() does three things, each in its own try/catch so none can stop the others:

  1. notificationsHandleEvent($event, $payload) (includes/notifications_router.php) - the notification bell subscribes here rather than at each call site.
  2. searchIndexHandleEvent($event, $payload) (includes/search/indexer.php) - the search corpus does the same (Search corpus).
  3. SELECT * FROM workflows WHERE trigger_event = ? AND is_active = 1, then run() for each row.

Matching is an exact string match on trigger_event. There are no wildcards, and the editor can only offer what availableTriggers() lists, so a new event needs both halves - an entry in availableTriggers() and a dispatch() call. Adding one, step by step: Extending the catalogue.

Two other ways to run actions, both outside dispatch():

  • manualFire($workflowId, $samplePayload, $dryRun) - one workflow, by id, with a payload the caller supplies. Used by api/workflow/fire.php and the REST fire route.
  • runActionList($label, $event, $actions, $payload) - a list of actions that is not a stored workflow. Called from FormsService (includes/services/forms.php, "when submitted") and includes/catalogue_approvals.php ("when approved / rejected"). Logged to workflow_executions with workflow_id NULL and $label as workflow_name. Never throws.

A run, step by step

run() is the loop-protection wrapper; runInner() is the run. The guards (the active-workflow stack, MAX_CHAIN_DEPTH = 10, MAX_RUNS_PER_REQUEST = 100) are explained in Engine internals. A refused run goes to recordAbortedRun(), which writes an aborted row with a loop_protection step.

runInner():

  1. Adds event to the payload if the host module did not supply one.
  2. enrichPayloadForTemplates() - enrichWithLookupNames() (*_name twins from FIELD_LOOKUP_TABLES), enrichWithReferences() (ticket.number, problem.number from RECORD_REFERENCE_COLUMNS), enrichWithLinks() (link, ticket.url, app.url). None of them overwrite a value the host module set.
  3. Inserts the running row with the enriched payload as trigger_payload, and stores the workflow id and execution id in $ctxWorkflowId / $ctxExecutionId so send_webhook can stamp its queue row.
  4. decodeJsonField() on conditions and actions (bad JSON becomes []).
  5. evaluateConditions() - see below. A failure makes the run skipped.
  6. executeActionList() - see below.
  7. Any exception becomes failed with an engine_error step.
  8. Updates the row (status, finished_datetime, step_log, error_message). Only when the run is neither manual nor a dry run does it update workflows.last_run_datetime, last_run_status and run_count.

Conditions

evaluateConditions() walks the list in order and stops at the first that fails (AND). Each condition read is dotGet($payload, $field), compared by evaluateOperator(), and logged as a step {kind: 'condition', index, field, op, value, actual, passed} - that actual is what the run log shows beside a skipped run.

The operators (availableOperators()): equals, not_equals, in, not_in, contains, not_contains, gt, lt, is_empty, is_not_empty. An unknown operator fails the condition. Comparison details that matter:

  • loose_equal() compares as strings, so "4" from the editor equals the integer 4 from the database, and null equals ''.
  • value_in_list() takes an array, a comma-separated string (older saves) or a single scalar.
  • contains / not_contains are case-sensitive str_contains() and need both sides to be strings.
  • gt / lt need both sides numeric, or they are false.

Which operators the editor offers per field, and where the value dropdowns come from (FIELD_TYPES, FIELD_LOOKUP_TABLES, availableValuesForField()): Conditions.

Actions

executeActionList() runs each {type, args} through executeAction(), a switch on type that calls a private action_<type>() method. An unknown type throws. On success the result is logged as {kind: 'action', index, type, status: 'success', result} and passed to mergeStepResult(); on an exception the step is logged failed and the list stops - the run's error is Action <i> (<type>): <message>.

Every action the code supports (the switch in executeAction() and the catalogue in availableActions() list the same 16):

Type Handler notes Returns (chainable keys)
log_message Writes nothing but the step message
set_ticket_status When the new status closes the ticket, calls ChecklistsService::assertClosureAllowed() and MandatoryFieldsService::assertClosureAllowed() first - the same close gates an analyst meets. Stamps or clears closed_datetime ticket_id, status_id, status_name
set_ticket_priority ticket_id, priority_id, priority_name
assign_ticket Sets both assigned_analyst_id and owner_id ticket_id, analyst_id, analyst_name
add_ticket_note Inserts into ticket_audit ticket_id, note_length
send_email Ticket's mailbox, or mailbox_id when there is no ticket (Sending an email without a ticket). A confidential ticket is only emailed to its requester or analysts - otherwise the step returns skipped ticket_id, to, subject
create_task Inserts into tasks, in the workflow's company, else the linked ticket's, else the event's. created_by_id is the workflow's author, else the first active administrator - the column is NOT NULL, and until 4.1.0 this stored NULL, so the action failed on every install task_id, title, status_id, priority_id, assignee_id, ticket_id
assign_ticket_team, set_ticket_department, set_ticket_category action_ticket_field(): one field through TicketsService::updateTicket() (actor 0, scope = the workflow's company), so audit, events and the service's rules apply. A category is first checked with ticketCategoriesResolved() against the ticket's company and type - the service only checks that it exists ticket_id, the value id, <what>_name
create_ticket With a submission.id in the payload it hands over to the shared form-to-ticket path in includes/catalogue_approvals.php; otherwise its own transaction. Numbers with TicketNumbering::next() and calls ticketSensitivityApplyDefaults() (One ticket, however a form raised it) ticket_id, ticket_number, subject
create_project Delegates to projectCreateFromAction() in includes/projects/intake.php whatever that returns
send_webhook Queues; see below queued, delivery_id, preset, url (host only), signed, bytes
escalate_to_tracker All the safety checks live in integrationsEscalate(); skips a confidential ticket (External issue trackers) ticket_id, external_key, external_url, status - or skipped, reason
send_note_to_tracker Posts to every linked issue; one unreachable tracker does not fail the step ticket_id, posted, count - or skipped, reason
attach_checklist Copies the chosen templates into ticket_checklists / ticket_checklist_items, skipping any already attached ticket_id, attached_count, checklists

A "skip" is a successful step whose result carries skipped: true - the run carries on. Only an exception stops the list.

Args are read through argString() (renders merge codes, trims), argInt() (null when blank or not numeric) and argBool() (a missing key gets the handler's default - skip_if_linked defaults to on). Action dropdowns come from ACTION_ARG_LOOKUPS via availableActionLookup(), which reuses FIELD_LOOKUP_TABLES.

Adding an action: an entry in availableActions() (label, description, args spec), a case in executeAction(), an action_<type>() method that throws on failure and returns an array, and a snippet case in the editor's renderNodeContent() if the node should show a summary. save.php refuses a type that is not in the catalogue, so the catalogue entry is not optional.

Chaining output

mergeStepResult() writes a non-empty array result to $payload['steps'][$index + 1] (one-based) and $payload['last']. Because executeActionList() takes $payload by reference, the next action's argString() can resolve {{last.ticket_id}} or {{steps.1.ticket_number}}. The merged values are not written back to trigger_payload - that column is the snapshot taken before the actions ran; to see what a step produced, read its result in step_log. Why the separate namespace and why one-based: Chaining.

Merge codes

renderTemplate() is one preg_replace_callback over {{ path }}: dotGet() the path, JSON-encode an array, empty string for anything missing. It never errors, which is why the editor's picker is scoped per trigger by availableVariables() and variablePrefixes() (Variable substitution).

Dry run

A dry run is manualFire(..., true). It goes through the same runInner(): the payload is enriched, the row is written with is_dry_run = 1, and conditions are evaluated for real. The difference is one branch at the top of the loop in executeActionList(): instead of executeAction() it logs {status: 'dry_run', would_run: describeAction($type), would_args: previewArgs($args, $payload)} and moves on. previewArgs() renders every string arg and replaces a non-empty secret with a "(set, redacted)" marker. Nothing is merged into steps / last, and run_count is untouched. What the user sees: Dry run.

Test fire

testFire() in the editor posts {id, payload: buildTestFirePayload(), dry_run} to fire.php, then re-reads get.php to refresh Recent runs. Both buttons need a saved workflow, because manualFire() loads it by id - an unsaved canvas is not what runs.

buildTestFirePayload() builds a payload that passes the workflow's own conditions (Test fire), reading field, op and value from each condition node. ⚠️ They live on the node itself (addConditionNode()), not under .data - until 4.1.0 it read c.data.field, so the payload was always empty and any workflow with an equals or in condition came back skipped on Test fire.

Webhooks: queue, worker, retries

action_send_webhook():

  1. Decrypts url and secret with webhookDecrypt() - they are stored encrypted in workflows.actions.
  2. ticketRedactForOutbound() (includes/ticket_sensitivity.php) cuts a confidential ticket down before anything else.
  3. If the format is full, or a custom body contains .full, enrichWithFullObjects() adds <key>.full from fullObjectLoaders() - the REST API's own select + serialiser per resource. Skipped for a confidential ticket.
  4. buildWebhookRequest() builds the body: full sends the record, a key from webhookFormats() goes through renderFormatBody(), custom renders body and checks it is valid JSON, and any other preset throws (a deleted format). Adds Content-Type, User-Agent: FreeITSM-Webhook/1 and, with a secret, X-FreeITSM-Signature: sha256=<hmac>.
  5. webhookEnqueue() inserts a pending row (URL encrypted again, next_attempt_at now, max_attempts 6) and returns its id.

buildWebhookRequest() is public because webhook_test.php and includes/integrations/msteams.php build their requests with it, so Send test cannot drift from a real send. Format details: Message formats.

The worker, cron/webhook_deliveries.php (schedule it every minute - Scheduled tasks):

  • Over HTTP it needs ?token= matching webhook_cron_token; from the CLI it does not. It refuses to run again within webhook_cron_min_interval_seconds (default 20) and records its run with JobRun (Background jobs).
  • webhookRunQueue($conn, 100, 90) first settles interrupted rows (below), then selects pending / failed rows that are due and claims each with UPDATE … SET status='delivering' WHERE id=? AND status IN ('pending','failed'). A row another worker claimed first has rowCount() = 0 and is skipped, so overlapping runs never double-send. The third argument is a time budget in seconds: it stops claiming once one more full-length try might not fit, because the worker runs under set_time_limit(120) and being killed mid-send is what leaves a row interrupted. What is left is due on the next run.
  • webhookAttemptDelivery() adds an X-FreeITSM-Delivery: <id> header (at send time, so it is not part of what the HMAC signs, and the same on every try and Replay) and sends with webhookHttpSend() (cURL, connect capped at 5 s, total = the timeout setting, TLS always verified via sslApplyCurl()). A 2xx is delivered. Otherwise failed with next_attempt_at pushed back by webhookBackoffSeconds() - 1 min, 5 min, 15 min, 1 h, then 6 h - until attempts reaches max_attempts, when it is dead.
  • Then webhookPurgePayloads() clears bodies past the payload-retention setting (webhook_payload_retention_days, default 7), and rows that are delivered / dead and older than webhook_delivery_retention_days (default 30) are deleted.

webhookReplay() puts a delivered / failed / dead row back to pending - but not one with payload_purged = 1, because Replay resends the stored body.

Delivery settings

webhookDeliverySettings($conn) reads three system_settings rows, set on System β†’ Webhooks β†’ Delivery by api/workflow/save_delivery_settings.php (admin only). Each allowed list is a constant beside the reader, and a stored value outside it falls back to the default - which is what FreeITSM did before the setting existed.

Key Allowed Default Used by
webhook_max_attempts WEBHOOK_ATTEMPTS_ALLOWED = 1, 3, 6, 10 6 webhookEnqueue(), copied into the row's max_attempts - a change does not move the goalposts for a delivery already queued
webhook_timeout_seconds WEBHOOK_TIMEOUT_ALLOWED = 5, 10, 15, 30, 60 15 every try; api/workflow/webhook_test.php caps it at 25 s to stay inside a web request's limit
webhook_interrupted_action retry, stop retry webhookRecoverInterrupted()

Interrupted rows. A worker killed between the claim and the result leaves the row delivering. webhookRecoverInterrupted() runs at the start of every queue run and settles any row still delivering after WEBHOOK_INTERRUPTED_AFTER_SECONDS (600 - far longer than any try or the worker's time limit, so it cannot still be in flight). retry counts it as a failed attempt with last_error starting "Interrupted", on the normal backoff, and dead at max_attempts; stop makes it dead at once, where Replay can pick it up. Before 4.1.0 nothing moved such a row on.

⚠️ retry can deliver twice: nobody knows whether the cut-off try arrived. That is why the X-FreeITSM-Delivery header exists, and why stop is offered for receivers - chat tools - that cannot drop a repeat. Test: php tests/webhook-delivery-settings.php.

Companies

4.1.0, #209. What it means for users: Workflows β†’ Companies.

A workflow is shared by design - the Knowledge convention, the opposite of tickets. workflows.tenant_id NULL = shared (runs for every company; every workflow from before 4.1.0); set = that company only. workflow_executions.tenant_id = the company the event belonged to; NULL = none known, readable only by someone who can see every company. Both are in includes/workflow_tenancy.php, and both no-op on a single-company install and before Database Verification has added the columns (workflowTenancyReady()).

⚠️ Not activeTenantFilter(): it reads NULL as "the Default company's" and would hide every shared workflow from every client company. Not apiKeyTenantFilter() in the REST resource, for the same reason.

Where What it does
dispatch() workflowEventTenantId() works out the event's company from the payload's main record (workflowEventRecordTables(): ticket, task, change, problem, asset, CMDB object, project, domain, contract, form submission, then the per-company settings rows) - read from the database, not the payload, so a Test fire payload cannot file a run under a company of its choosing. Then tenant_id IS NULL OR tenant_id = ?, or IS NULL alone when the event has no company
runInner() Sets $ctxWorkflowTenant / $ctxEventTenant, puts company on the payload, stamps the run. run() saves and restores the context around every run: an action can dispatch an event whose workflows run inside this one, and coming back with their company set would let this workflow act as if it were theirs
The action handlers assertRecordInRunCompany() (tickets, form submissions - NULL = Default), assertSharedOrRunCompany() (mailboxes, checklist templates - NULL = shared), assertAnalystReachesRunCompany() (assignees, project managers). Only for a company's own workflow - a shared one is unchanged. Created tickets, tasks and projects go to the workflow's company (projectCreateFromAction() takes it as $forTenant)
runActionList() A form's own list is no company's workflow, so no guard - but its run is filed under the event's company
withCompanyField() Adds company.id (a lookup on tenants) to every trigger's condition fields, multi-company installs only
api/workflow/* list (workflowListFilter(): shared + the switched-to company), get (can_edit; webhook URL and secret shown as (hidden) to someone who cannot edit), save / delete / fire (analystCanWriteWorkflowFor() on the stored company and the new one), executions / execution_detail (workflowExecutionFilter() on the list, the facets and the tallies), create_from_template, deliveries_replay, webhook_test (its sample ticket comes from the workflow's company or the switched-to one)
Elsewhere The Watchtower workflow card (includes/watchtower_queries.php) counts only runs the viewer can read
REST api/v1/resources/workflows.php - see REST API: Workflow

Who may write: analystCanWriteWorkflowFor(null) needs analystHasAllTenantAccess(); a company id needs analystCanAccessTenant(). A new workflow with no company in the request is shared for an every-company analyst and the switched-to company for anyone else.

Before 4.1.0 none of this existed: every workflow ran for every company, and the run log, the editor's recent runs, the Send test sample ticket and the REST run endpoints were not limited by company. The FK on workflows.tenant_id is ON DELETE CASCADE - SET NULL would turn a deleted company's workflows into shared ones.

Test: php tests/workflow-company-scope.php (scratch database, N=1, N=3 and the columns-missing case, 87 checks).

Time-based triggers

Nothing is written when time passes, so a cron finds the situation and calls workflowEmitOnce($conn, $event, $entityKey, $fingerprint, $payload) instead of dispatch():

  1. Nobody listening, nothing recorded. If no active workflow has that trigger_event, it returns without touching the ledger (Pitfall #9).
  2. INSERT IGNORE INTO workflow_scheduled_emissions. Only the call whose insert created a row (rowCount() === 1) goes on - the unique key uq_wse_once makes the database the referee between overlapping runs.
  3. If the ledger table is missing, it does not dispatch (re-firing every few minutes would be worse than silence).
  4. WorkflowEngine::dispatch().

entity_key says what it is about (ticket:183:response); fingerprint says what state it fired against (for SLA, the target minutes), so a changed deadline re-arms. workflowPruneEmissions() drops ledger rows older than a year.

Triggers Found by
sla.warning, sla.breached sla_emit_workflow_event() in includes/sla_notifications.php, run by cron/sla_breach_check.php
contract.expiring, asset.warranty_expiring workflowEmitContractExpiries() / workflowEmitWarrantyExpiries() at the windows in workflowExpiryWindows() (90, 30, 7, 1 days), run by cron/workflow_scheduled.php
project.* time-based events projectAlertsRun() in includes/projects/alerts.php, also run from workflowScheduledRun(); some of it writes the same ledger directly
domain.* includes/domains/alerts.php and watch.php, run by cron/domains.php; fire-once through their own domain_alerts_sent claim
tracker.issue_* Not time-based, but found by cron/integration_poll.php

The user-facing account, including how to test by hand: Time-Based Triggers.

Errors, history and where to look

What went wrong Where it ends up
A condition did not match skipped row; the failing condition's step has passed: false and the actual value
An action threw failed row; that step has status: 'failed' and error; later actions never ran
The engine itself threw failed row with an engine_error step
Loop protection refused aborted row with a loop_protection step
The database was unreachable in dispatch() PHP error_log only ([WorkflowEngine::dispatch]) - there is no row to write
A webhook was refused Not the run - the run succeeded when it queued. The webhook_deliveries row: last_status_code, last_error, response_snippet

The engine has no retry of its own - a failed run is not repeated. Only webhook deliveries retry. The run log page, the payload snapshot and the Watchtower card (getWatchtowerData() in includes/watchtower_queries.php): The execution log.

The canvas editor

workflow/editor.php puts the engine's catalogues on window so the editor never keeps its own copy: WF_TRIGGERS, WF_OPS, WF_ACTION_DEFS, WF_FIELDS_BY_TRIG, WF_VARS_BY_TRIG, WF_VAR_PREFIXES, WF_LOOKUP_VALUES, WF_FIELD_TYPES, WF_ACTION_LOOKUPS, WF_FORMATS, plus WF_ID and WF_API. Everything else is inside the WFE module in workflow-editor.js; editor.php's buttons call the functions it returns (WFE.save, WFE.testFire, WFE.dryRun, WFE.addCondition, …).

In memory, a workflow is workflow ({id, name, description, isActive}), triggerEvent, and nodes - an array of {id, kind, x, y, el} where kind is trigger, condition (plus field, op, value) or action (plus type, args). Local node ids are negative and never saved. Positions are snapped to the 20 px grid (GRID, snap()). Connectors are drawn at render time by renderConnectors() from the y-sorted order and are not stored.

The action form is built from the action's args spec by renderActionArgsForm(). Arg types it draws: text (the fallback), textarea, numeric, bool, lookup, select, checklist_multiselect. show_when: {preset: [...]} hides a field unless another arg has one of those values (applyArgVisibility()); supports_vars adds the merge-code picker; default seeds a new action's args (addAction(), updateActionTypeFromDetail() - changing an action's type replaces its args). Clearing a text arg deletes the key.

Saving - save() sorts condition and action nodes by y and posts this to save.php (the shape is exactly what save() builds; the values are the "Alert when a ticket is escalated to P1" recipe once cloned and filled in, with an example id, priority and URL):

{
  "id": 42,
  "name": "Alert when a ticket is escalated to P1",
  "description": "",
  "trigger_event": "ticket.priority_changed",
  "conditions": [
    { "field": "ticket.priority_id", "op": "equals", "value": "4", "x": 220, "y": 200 }
  ],
  "actions": [
    { "type": "send_webhook",
      "args": { "preset": "slack", "url": "https://hooks.slack.com/services/…",
                "message": ":arrow_up: *Escalated to {{ticket.priority_name}}* - {{ticket.subject}} ({{ticket.number}})" },
      "x": 220, "y": 420 },
    { "type": "add_ticket_note",
      "args": { "ticket_id": "{{ticket.id}}", "note": "Escalated to P1 - the team has been alerted in chat." },
      "x": 220, "y": 560 }
  ],
  "is_active": 1
}

id is null for a new workflow; the reply's id is then put in the address bar with history.replaceState. The engine ignores x / y. workflows.conditions and workflows.actions hold those two arrays as JSON, with url and secret encrypted. The trigger node has no stored position.

Loading - loadWorkflow() reads get.php, adds the trigger node, and places each condition and action at its stored x / y, or stacks it below the previous one when it has none (recipes and REST-created workflows have none). aiApply() does the same stacking for an AI proposal; collectWorkflowForApi() builds the same shape for ai_compose.php, without positions when asked.

TRAPs and things that bite

Grep TRAP: in these files:

  • change.submitted and friends (availableTriggers()): "TRAP: dispatched from ChangesService - both halves, here AND there, or the event can never be heard." A dispatch() with no catalogue entry matches nothing and nothing reports it.
  • Ticket numbers in create_ticket: "TRAP: the install's numbering, never a number made here" - use TicketNumbering::next(), as action_create_ticket() does.

And the warnings in the code worth knowing before changing it:

  • FULL_OBJECT_KEYS must list the same keys as fullObjectLoaders() - the picker reads the constant without a database connection.
  • renderFormatBody() decodes the format's template and substitutes inside the string values, then re-encodes. String-substituting into raw JSON would let a quote in a message break or inject into the payload.
  • webhookFormats() falls back to BUILTIN_WEBHOOK_FORMATS on any database error, so a broken formats table cannot stop webhooks. custom and full (RESERVED_FORMAT_KEYS) are never rows.
  • Webhook url / secret are not masked in the editor, on purpose: an action's identity is its position on the canvas, so a masked placeholder could be restored onto the wrong action after a drag. The reasoning is in save.php. Every route that saves actions goes through webhookEncryptActions().
  • create_ticket lazily requires includes/catalogue_approvals.php, because that file requires the engine - a top-level require would be circular.

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally