Repository navigation
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.
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
|
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.
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).
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(). -
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
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π§© Preference templates
- π Display order (modules, Watchtower cards) β Developer Guide
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ ποΈ Mobile: Files
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π οΈ Long forms in a task's Work view - review (#198)
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ π§βπΌ REST API: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π οΈ Inbox.js map β Developer Guide
- β³ π οΈ Tickets settings β Developer Guide
- β³ π Ticket workspace - design (PR #206)
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Case reports - a ticket and its work as one PDF
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π§© Asset type layouts
- β³ π Inventory freshness
- β³ π Connected assets
- β³ π Asset review
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- π‘ Zabbix (monitoring)
- π¬ Microsoft Teams alerts
- π 3CX (phone system)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π³ The Docker image was missing PHP extensions
- β³ π A failed DNS lookup read as a missing record
- β³ π A link could change data
- β³ π Access outlived its removal
- β³ π Knowledge articles could run scripts
- β³ π Knowledge writes ignored permissions
- β³ π Company boundaries (Oct 2026)
- β³ π Smaller gaps (Oct 2026)
- β³ π Escaped text could leave its attribute
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96