Workflow Engine Developer Guide - edmozley/freeitsm GitHub Wiki
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.
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.
| 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 |
| 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).
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).
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:
-
notificationsHandleEvent($event, $payload)(includes/notifications_router.php) - the notification bell subscribes here rather than at each call site. -
searchIndexHandleEvent($event, $payload)(includes/search/indexer.php) - the search corpus does the same (Search corpus). -
SELECT * FROM workflows WHERE trigger_event = ? AND is_active = 1, thenrun()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 byapi/workflow/fire.phpand the RESTfireroute. -
runActionList($label, $event, $actions, $payload)- a list of actions that is not a stored workflow. Called fromFormsService(includes/services/forms.php, "when submitted") andincludes/catalogue_approvals.php("when approved / rejected"). Logged toworkflow_executionswithworkflow_idNULL and$labelasworkflow_name. Never throws.
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():
- Adds
eventto the payload if the host module did not supply one. -
enrichPayloadForTemplates()-enrichWithLookupNames()(*_nametwins fromFIELD_LOOKUP_TABLES),enrichWithReferences()(ticket.number,problem.numberfromRECORD_REFERENCE_COLUMNS),enrichWithLinks()(link,ticket.url,app.url). None of them overwrite a value the host module set. - Inserts the
runningrow with the enriched payload astrigger_payload, and stores the workflow id and execution id in$ctxWorkflowId/$ctxExecutionIdsosend_webhookcan stamp its queue row. -
decodeJsonField()onconditionsandactions(bad JSON becomes[]). -
evaluateConditions()- see below. A failure makes the runskipped. -
executeActionList()- see below. - Any exception becomes
failedwith anengine_errorstep. - Updates the row (
status,finished_datetime,step_log,error_message). Only when the run is neither manual nor a dry run does it updateworkflows.last_run_datetime,last_run_statusandrun_count.
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 integer4from the database, andnullequals''. -
value_in_list()takes an array, a comma-separated string (older saves) or a single scalar. -
contains/not_containsare case-sensitivestr_contains()and need both sides to be strings. -
gt/ltneed 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.
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
|
set_ticket_field (#278) |
action_set_ticket_field(): TicketsService::updateTicket(['custom_fields' => [$key => $value]]) with actor 0 and the workflow's company, so TicketFieldsService::validate() checks the type and that the field applies to this ticket, and write() records the change in ticket_audit with no analyst. The editor's field list is ticketCustomFieldLookup(), special-cased in availableActionLookup() because a custom field is not a payload path. A custom-field-only change dispatches no event, so the action cannot trigger itself |
ticket_id, field_key, value
|
assign_ticket_team, set_ticket_department, set_ticket_category, set_ticket_type
|
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, and a type (4.8.0, #230) with ticketTypeUsable() - the service only checks that they exist |
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.
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.
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).
.full in any action (#275). executeActionList() calls fullForTemplates($args, $payload) before every action except send_webhook (which has its own path below), and in the dry run too:
private static function fullForTemplates(array $args, array $payload): array
{
$mentions = false;
array_walk_recursive($args, function ($v) use (&$mentions) {
if (is_string($v) && str_contains($v, '.full')) $mentions = true;
});
if (!$mentions) return $payload; // no query for a plain action
$tid = (int)($payload['ticket']['id'] ?? ($payload['ticket_id'] ?? 0));
if ($tid > 0 && ticketIsConfidential($conn, $tid)) return $payload; // never a confidential ticket
return self::enrichWithFullObjects($conn, $payload); // skips a record already loaded
}TRAP:
.fullmust never reach an action for a confidential ticket. The webhook redacts a confidential ticket withticketRedactForOutbound()before it enriches. Without the same check here, an email action could get round that with{{ticket.full.subject}}. A confidential ticket simply has no.full, so the code renders blank.
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.
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), readingfield,opandvaluefrom each condition node.⚠️ They live on the node itself (addConditionNode()), not under.data- until 4.1.0 it readc.data.field, so the payload was always empty and any workflow with anequalsorincondition came backskippedon Test fire.
action_send_webhook():
- Decrypts
urlandsecretwithwebhookDecrypt()- they are stored encrypted inworkflows.actions. -
ticketRedactForOutbound()(includes/ticket_sensitivity.php) cuts a confidential ticket down before anything else. - If the format is
full, or a custombodycontains.full,enrichWithFullObjects()adds<key>.fullfromfullObjectLoaders()- the REST API's own select + serialiser per resource. Skipped for a confidential ticket. -
buildWebhookRequest()builds the body:fullsends the record, a key fromwebhookFormats()goes throughrenderFormatBody(),customrendersbodyand checks it is valid JSON, and any other preset throws (a deleted format). AddsContent-Type,User-Agent: FreeITSM-Webhook/1and, with a secret,X-FreeITSM-Signature: sha256=<hmac>. -
webhookEnqueue()inserts apendingrow (URL encrypted again,next_attempt_atnow,max_attempts6) 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=matchingwebhook_cron_token; from the CLI it does not. It refuses to run again withinwebhook_cron_min_interval_seconds(default 20) and records its run withJobRun(Background jobs). -
webhookRunQueue($conn, 100, 90)first settles interrupted rows (below), then selectspending/failedrows that are due and claims each withUPDATE … SET status='delivering' WHERE id=? AND status IN ('pending','failed'). A row another worker claimed first hasrowCount() = 0and 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 underset_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 anX-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 withwebhookHttpSend()(cURL, connect capped at 5 s, total = the timeout setting, TLS always verified viasslApplyCurl()). A 2xx isdelivered. Otherwisefailedwithnext_attempt_atpushed back bywebhookBackoffSeconds()- 1 min, 5 min, 15 min, 1 h, then 6 h - untilattemptsreachesmax_attempts, when it isdead. - Then
webhookPurgePayloads()clears bodies past the payload-retention setting (webhook_payload_retention_days, default 7), and rows that aredelivered/deadand older thanwebhook_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.
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.
⚠️ retrycan deliver twice: nobody knows whether the cut-off try arrived. That is why theX-FreeITSM-Deliveryheader exists, and whystopis offered for receivers - chat tools - that cannot drop a repeat. Test:php tests/webhook-delivery-settings.php.
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()).
⚠️ NotactiveTenantFilter(): it reads NULL as "the Default company's" and would hide every shared workflow from every client company. NotapiKeyTenantFilter()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).
Nothing is written when time passes, so a cron finds the situation and calls workflowEmitOnce($conn, $event, $entityKey, $fingerprint, $payload) instead of dispatch():
-
Nobody listening, nothing recorded. If no active workflow has that
trigger_event, it returns without touching the ledger (Pitfall #9). -
INSERT IGNORE INTO workflow_scheduled_emissions. Only the call whose insert created a row (rowCount() === 1) goes on - the unique keyuq_wse_oncemakes the database the referee between overlapping runs. - If the ledger table is missing, it does not dispatch (re-firing every few minutes would be worse than silence).
-
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.
| 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.
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.
Grep TRAP: in these files:
-
change.submittedand friends (availableTriggers()): "TRAP: dispatched from ChangesService - both halves, here AND there, or the event can never be heard." Adispatch()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" - useTicketNumbering::next(), asaction_create_ticket()does.
And the warnings in the code worth knowing before changing it:
-
FULL_OBJECT_KEYSmust list the same keys asfullObjectLoaders()- 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 toBUILTIN_WEBHOOK_FORMATSon any database error, so a broken formats table cannot stop webhooks.customandfull(RESERVED_FORMAT_KEYS) are never rows. - Webhook
url/secretare 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 insave.php. Every route that saves actions goes throughwebhookEncryptActions(). -
A mailbox row is ciphertext until
decryptMailboxRow(). Its tenant, client, secret, address and IMAP / SMTP logins are stored encrypted;action_send_email()once read the Send from row raw and every send from it failed (#216). -
A ticket's mailbox is
emails.mailbox_idon its opening message, not a ticket column.create_ticketwrites one throughmailboxForNewTicket(); without it nobody can reply to the ticket (#216). -
create_ticketlazily requiresincludes/catalogue_approvals.php, because that file requires the engine - a top-level require would be circular.
- Workflows - the concepts this page assumes · Webhooks · Webhook message formats · Time-Based Triggers
- Workflow & Webhook Pitfalls · The "Add a note" action that could never work · Code traps
- Form submission actions · Catalogue request approvals · Service layer · REST API: Workflow