Workflows - edmozley/freeitsm GitHub Wiki

Workflows

Folder: workflow/ Theme colour: Amber #f59e0b DB tables: workflows, workflow_executions, webhook_deliveries

Cross-module automation engine. Other modules fire events when things happen ("a ticket was just created", "a priority just changed"); the Workflows module listens for those events, evaluates user-defined conditions against the event's payload, and runs an ordered list of actions when the conditions pass. The strategic answer to "why pick FreeITSM over Halo / ServiceNow" on the automation axis.

A workflow has three parts: a single trigger (the event), zero or more conditions (filters, AND semantics), and one or more actions (what to do).

What makes a workflow run โ€” and what you have to schedule

The most common question, and the one it's easiest to get a wrong mental model of. There are three different cases, and they behave differently.

1. Detecting the event โ€” nothing is scheduled

Making a ticket P1 fires the workflow immediately. Nothing polls. Nothing checks on a timer.

When a ticket is saved, the code that writes it calls WorkflowEngine::dispatch('ticket.priority_changed', โ€ฆ) in the same request, synchronously. The engine finds the active workflows for that event, evaluates their conditions, and runs their actions โ€” all before the browser gets its response back. It's a phone call, not a letter.

That's why every trigger in the catalogue hangs off a write path: something has to happen for an event to exist. No scheduled task is involved, and none is needed.

2. Delivering a webhook โ€” this DOES need a scheduled task โš ๏ธ

Here's the part that catches people.

The send_webhook action does not POST to Slack or Discord. It queues the message into webhook_deliveries and returns instantly. A background worker โ€” cron/webhook_deliveries.php โ€” does the actual sending, with retries and backoff.

That's deliberate and it's the right design: if Discord is down or slow, your ticket still saves instantly. Nobody should wait on someone else's outage to file a ticket.

But it means the worker has to be scheduled, or nothing ever leaves the building. A workflow will fire perfectly, queue its message, and the message will sit there forever.

The trap. The editor's Send test button is synchronous โ€” it bypasses the queue deliberately, so you get an immediate answer. So Send test can work perfectly while every real webhook silently piles up unsent. If your test posts to Discord but your live P1 doesn't, this is why.

System โ†’ Webhooks shows a red banner when the worker isn't running, with the exact command to schedule. Set it to run every minute. Setup: docs/webhook-cron-setup.md.

3. Time passed โ€” a scheduled job goes looking

"The SLA is about to breach." "This contract expires in 30 days."

These are not events. Nothing happened โ€” time passed. There's no write to hang a dispatch() off, so a scheduled job has to go and find them.

Four triggers work this way: sla.warning, sla.breached, contract.expiring, asset.warranty_expiring.

โš ๏ธ If the crons aren't scheduled, these triggers never fire. The workflow sits there, active, doing nothing, and nothing tells you why. See Time-based triggers below.

The summary

Scheduled task needed?
Something happened (ticket created, priority changed, form submitted) No. Instant, event-driven, in-process.
A webhook needs sending Yes โ€” the delivery worker, every minute.
Time passed (SLA breach, contract expiry) Yes โ€” the SLA cron and/or cron/workflow_scheduled.php.

Time-based triggers โ€” and the problem of not firing twice

Seven triggers โ€” sla.warning, sla.breached, contract.expiring, asset.warranty_expiring, project.stage_due, project.milestone_due and project.milestone_missed โ€” don't hang off a write path. Nothing happened; time passed. A scheduled job has to go looking for them, and once it does, the hard part isn't finding a breached SLA โ€” it's not telling you about it 300 times, because a time-based condition stays true.

A fire-once ledger (workflow_scheduled_emissions, unique key + INSERT IGNORE) makes the database the arbiter, and a fingerprint of the state it fired against lets a situation re-arm when the thing underneath it changes โ€” so raising a ticket's priority, which shrinks its SLA target, is allowed to escalate again against the new deadline.

โš ๏ธ These seven need crons scheduled, or they never fire at all โ€” the workflow sits there, active, doing nothing, and nothing tells you why.

๐Ÿ“– Full write-up: Time-Based Triggers โ€” the triggers and their crons, the ledger, the fingerprint, the four starter recipes, cron setup, and how to test the whole thing by hand without waiting for a scheduler.

Anatomy

Part What it is
Trigger One of the catalogued events, e.g. ticket.created, problem.status_changed, change.approved, asset.assigned, cmdb.object.created, knowledge.published โ€” see the full event catalogue. Exactly one per workflow. Adding a new trigger = one entry in WorkflowEngine::availableTriggers() + one dispatch() call from the host module's service (the single write path shared by the UI and the API, so the event fires no matter how the change was made).
Conditions Each condition is {field, op, value}. The engine reads field from the dispatch payload via dot-notation (ticket.priority_id). All conditions must match (AND); if empty the workflow always fires.
Actions Each action is {type, args}. Execute in order; if one fails the chain stops and the rest are marked unrun. The engine's per-step try/catch logs every failure to the execution row's step_log without bubbling the exception out to the host module.

A few catalogue details worth knowing beyond the event catalogue itself: UI settings endpoints emit their CRUD events via a best-effort wf_emit() helper, while shared services call the engine directly; task.completed fires only on the transition into a closed status (not on every save of an already-closed task); software.application_discovered fires from all three agent-ingest paths when a new application first appears; and form.submitted's payload includes a submission.fields map of every answer keyed by its label, plus the first email-type answer as submission.email.

Catalogue requests add three more form-shaped triggers: catalogue_request.submitted (a gated form was submitted and is waiting on its approver โ€” a gated submission fires this instead of form.submitted, so an auto-raise rule can't jump the approval gate), catalogue_request.approved and catalogue_request.rejected. All three carry form.id, so a rule narrows to one catalogue item rather than every form on the install, and all three carry the same submission.fields map โ€” the four form-shaped triggers share one SUBMISSION_FIELD_TRIGGERS const precisely so that {{submission.fields.Start date}} cannot mean one thing at submission and nothing at approval. The decision events additionally carry request.comment and, on approval, request.ticket_id / request.ticket_number.

โš ๏ธ A broken lookup is indistinguishable from a missing one. form.id had an entry in FIELD_LOOKUP_TABLES all along, reading a column called name โ€” the forms table has title. The query threw, availableValuesForField() caught it and returned null, and the editor drew an empty tick-list. So "match on this form" was offered and could never be filled in, and the only rule anyone could save ran for every form on the install. Worth a periodic sweep: exercise every entry in that map against the database, because the catch means nothing else ever will. All 29 pass as of #95.

โš ๏ธ The dropdown lists leaves only. Editing a form forks it into a new row and the frozen versions keep their titles, so listing every row offers the same name several times with no way to tell which is current. โš ๏ธ It follows that a condition pinned to form.id stops matching once that form is versioned โ€” the submission carries the new leaf's id. That trap is one of the reasons a form's own action list belongs on the form (see the Catalogue Request Approvals developer guide), where versioning carries it forward instead.

โš ๏ธ These three were dispatched from the day approvals shipped but never catalogued, so for their whole life they matched zero workflows and the approver was never told anything was waiting. dispatch() selects on trigger_event and the editor can only offer what availableTriggers() lists โ€” so a dispatch() call without a catalogue entry is an event that can never be heard, and nothing anywhere reports the mismatch. Worth checking whenever a new event is added. See the Catalogue Request Approvals developer guide.

Conditions and actions are stored as JSON-in-TEXT columns on the workflows table โ€” so the schema doesn't need a migration every time the engine grows a new operator or action kind.

Visual canvas builder

Process Mapper-style canvas with a dot-grid background, 20px snap-to-grid, and auto-routed SVG connectors. Three node shapes:

  • Trigger node โ€” amber pill, pinned to top-centre. One per workflow; can't be deleted.
  • Condition node โ€” orange diamond (clip-path).
  • Action node โ€” blue rounded rectangle.

Position IS the order. Execution flow at save time is trigger โ†’ conditions (sorted top-to-bottom by y) โ†’ actions (sorted top-to-bottom by y). Drag a condition above another to swap them. No branching in v1 โ€” purely linear visual.

The toolbar has Back / Add condition / Add action / Status pip (idle / unsaved / saving / saved) / Test fire / Save / AI co-author.

The detail panel slides in from the right with four bodies swapped by selection:

Body Shown when
Workflow Nothing selected (click empty canvas) โ€” name / description / active flag / recent runs
Trigger Trigger node clicked โ€” event dropdown sourced from WorkflowEngine::availableTriggers()
Condition Condition node clicked โ€” field / operator / value
Action Action node clicked โ€” type dropdown + per-arg form

Right-click or Delete removes the selected node (except the trigger). Same mouseup-no-drag pattern Process Mapper landed in #335 so a drag never flashes the detail panel in.

Conditions

The condition value control adapts to the chosen field type โ€” driven by two const maps on WorkflowEngine:

  • FIELD_LOOKUP_TABLES โ€” normalised id fields (ticket.priority_id, ticket.status_id, etc.) get a checkbox list of real values from the joined table. Multi-tick gives OR semantics โ€” "priority is Critical OR High" is one condition with op: in, value: ["4", "3"] rather than two AND-ed conditions which doesn't actually express OR.
  • FIELD_TYPES โ€” drives operator filtering. Numeric fields offer equals / not_equals / in / not_in / gt / lt / is_empty / is_not_empty (no contains โ€” substring search on a number is meaningless). Text fields offer the same minus gt / lt (lexicographic string compare is a footgun) plus contains / not_contains. Lookup fields collapse to is / is not / is empty / is not empty โ€” the multi-select handles the rest.

The editor's field dropdown is populated from WorkflowEngine::availableFields($trigger) โ€” every ticket.* trigger exposes the full ticket field set (id / subject / priority_id / status_id / department_id / type_id / assigned_analyst_id / owner_id / origin_id / created_by / requester_email) plus event-specific extras (old_status_id / new_status_id / old_priority_id / new_priority_id / analyst_id / team_id).

Actions

Eight handlers ship today. Each action's args spec drives the editor UI dynamically โ€” the user gets a labelled form with the right widget per arg (text input / textarea / number / checkbox / lookup <select>), not a JSON textarea.

Type What it does Key args
log_message Writes a message into the workflow's execution log. Placeholder while scaffolding rules. message
set_ticket_status Changes a ticket's status. Auto-stamps / clears closed_datetime when the new status's is_closed flag flips. ticket_id, status_id
set_ticket_priority Sets a ticket's priority. ticket_id, priority_id
assign_ticket Sets a ticket's owner/assignee. Updates both assigned_analyst_id and owner_id to mirror assign_ticket.php's explicit-analyst branch. ticket_id, analyst_id
add_ticket_note Appends a Workflow Note row to ticket_audit with analyst_id = NULL so the UI can render workflow-generated notes distinctly. ticket_id, note
send_email Sends an email using the ticket's mailbox. Reuses every helper in includes/template_email.php โ€” mailbox lookup, token refresh, Graph / Gmail dispatch, templateSaveSentEmail, the [SDREF:XXX-NNN-NNNNN] reply-marker so replies thread back. Defaults the recipient to the requester. ticket_id, to, subject, body
create_task Inserts into tasks mirroring api/tasks/save.php โ€” is_default fallback for status/priority, MAX(board_position) + 1 calc, auto-links the source ticket. title, description, status_id, priority_id, assignee_id, ticket_id
create_ticket Full transactional mirror of api/tickets/create_ticket.php โ€” upserts the user row by email, generates a unique XXX-NNN-NNNNN number, inserts the ticket, creates an initial Manual-direction email entry so the new ticket appears in the inbox, writes an audit row. subject, body, priority_id, department_id, type_id, assigned_analyst_id, from_email, from_name
create_project (3.3.0) A project proposal through projectCreateFromAction() (includes/projects/intake.php) - status proposed, waiting for approval when Projects โ†’ Settings says so; from a form, the requester's company, name and email are recorded and blank boxes are filled from the submission. Returns project_id, code, name, approval. name, summary, business_case, estimated_cost, estimated_benefit, target_end_date, methodology, priority, owner_analyst_id
send_webhook Outbound webhook โ€” POSTs a JSON payload to an external URL, the universal way to push events into Slack, Teams, Discord, PagerDuty, Zapier/Make, n8n or any endpoint that accepts an incoming webhook. preset = Slack/Teams/Discord (wraps the templated message in that platform's JSON) or Custom (sends the raw templated body, which must be valid JSON). An optional secret HMAC-SHA256-signs the body as X-FreeITSM-Signature: sha256=โ€ฆ for authenticity. Delivery is asynchronous โ€” the action enqueues to webhook_deliveries and returns instantly; a background worker sends it with retries + backoff and dead-lettering. The signing secret is computed at enqueue time and only the header stored (secret never persisted). Track every send under Workflows โ†’ Webhook deliveries (status, attempts, response, Replay). preset, url, message, body, secret

Action lookups (status / priority / analyst / department / ticket_type / task_status / task_priority) reuse FIELD_LOOKUP_TABLES via the ACTION_ARG_LOOKUPS map and availableActionLookup($key) so action dropdowns and condition value dropdowns share one source of truth.

Outbound webhooks in practice

A webhook is FreeITSM making other systems do things: when a rule fires, it POSTs to a URL you provide. FreeITSM stays deliberately ignorant of the target โ€” the receiving system publishes an "incoming webhook URL," you paste it in, and (for anything beyond the presets) you shape the payload to match what that system expects. This is why one action reaches thousands of tools without a per-integration build.

Slack / Teams / Discord (preset). In the target app, create an Incoming Webhook and copy its URL. In the workflow, add Send a webhook, set Format to the platform, paste the URL, and write the Message (templated, e.g. P1 raised: {{ticket.subject}} (#{{ticket.id}})). FreeITSM wraps it in the platform's shape โ€” Slack {"text": โ€ฆ}, Discord {"content": โ€ฆ}, Teams a MessageCard.

Anything else (Custom). Set Format to Custom (raw JSON body) and write the exact JSON the target wants, with {{variables}} filled in. For maximum reach, point it at a Zapier / Make / n8n webhook and let that fan out to any app.

Full-record loading under the hood. The full-object enrichment is a registry of loaders keyed by payload object โ€” WorkflowEngine::fullObjectLoaders() โ€” one per resource with a clean API GET /{id}. Each loader runs the resource's own raw SELECT + serialiser rather than the apiLoadX() helpers, because those apiError()/exit on a missing row and would abort the host module's request. Loading is best-effort as well as lazy: if a load fails, .full is simply omitted rather than failing the run.

Verifying authenticity (signing secret). Set a secret and FreeITSM adds X-FreeITSM-Signature: sha256=<hex>, where the hex is HMAC-SHA256(raw_body, secret). The receiver recomputes the HMAC over the exact bytes received and compares โ€” a match proves the call came from your install and wasn't tampered with. (PHP receiver: hash_equals('sha256='.hash_hmac('sha256', file_get_contents('php://input'), $secret), $_SERVER['HTTP_X_FREEITSM_SIGNATURE']).)

Note

Delivery is asynchronous (shipped): the action enqueues to webhook_deliveries and returns instantly; the cron worker cron/webhook_deliveries.php sends with retries + exponential backoff (1mโ†’5mโ†’15mโ†’1hโ†’6h) and dead-letters after max_attempts. Schedule it every minute (docs/webhook-cron-setup.md); latency โ‰ˆ the cron interval. Workflows โ†’ Webhook deliveries logs every send with a Replay button. Still ahead: a wider event catalogue so webhooks fire from across the whole product (see What's still ahead).

Starter templates

A blank canvas is a bad first experience. New from template on the Workflows list opens a gallery of 24 ready-made recipes for the jobs most service desks automate first, across 13 categories โ€” filterable by category and searchable (the search reaches the description, trigger and actions too, so expiry, slack or wipe find the right card even when the name doesn't contain the word).

Tickets and incidents: auto-acknowledge a new ticket, thank the requester when it closes, P1 incident response, alert when a ticket is escalated to P1 (the case a create-time rule misses), broadcast a status-page incident, sound the all-clear when it's resolved (teams remember the outage and never hear it ended), log every ticket that's binned. Onboarding: new-starter form โ†’ IT + HR + Facilities tickets + a task to chase them. Change: flag a high-risk change for CAB review, raise an implementation task when one is approved. Assets: chase the acceptable-use paperwork when kit is issued, wipe-and-restock when it comes back. Also: root-cause investigation on a new problem, supplier due diligence, contract setup, a ticket when a morning check fails, a shadow-IT alert on newly discovered software, an alert when a CI is deleted, an orphaned-link check when an article is archived, and announcing a published article.

Four are time-based โ€” the recipes that were impossible before the scheduled triggers landed:

Recipe Trigger What it does
Escalate before the SLA breaches sla.warning Raises priority and alerts the team while there's still time to save it.
Alert when an SLA is breached sla.breached Says so out loud, rather than letting it pass unremarked.
Contract renewal reminder contract.expiring Raises a ticket 30 days out, so renewal is a decision rather than a discovery.
Asset warranty expiry reminder asset.warranty_expiring Extend it or plan the replacement while the machine is still covered.

โš ๏ธ These four need the crons scheduled โ€” see Time-based triggers. Without them the workflow sits there, active, and never fires. The two expiry recipes fire at 90 / 30 / 7 / 1 days out and each ships filtered to the 30-day window (window_days equals 30); change or drop that condition to be told at every window.

Pick one and it's copied in as a normal, fully editable workflow. There's nothing special about it afterwards โ€” rename it, rewire it, delete it.

The design problem a recipe library has to solve

Action args like status_id are numeric IDs that differ per install. A recipe cannot hardcode status_id: 5 โ€” your "Closed" might be 5, mine might be 9, and someone else calls it "Done".

So a template declares its lookups by name, and the resolver maps them to this install's real IDs at clone time:

'value' => ['$lookup' => 'ticket_status', 'names' => ['Closed', 'Resolved', 'Complete', 'Done']],

Candidate names are tried in order, so a desk with both Critical and High correctly gets Critical for P1. Values only the user can supply (a Slack webhook URL) use a ['$configure' => 'โ€ฆ'] marker instead.

Anything that can't be resolved is cloned blank and reported in an unresolved list, which the editor renders as a "these fields still need you" banner. A recipe always clones โ€” it just tells you honestly what it couldn't fill in, rather than silently half-working.

Cloned workflows arrive inactive. Nothing a recipe contains can fire until it's been read, filled in and switched on.

Every template's trigger and action types are checked against the live engine catalogues โ€” a recipe that couldn't actually run would be worse than no recipe.

Key file: workflow/includes/templates.php


Chaining โ€” what an earlier action produced

Until #95 every action ran in the dark. runInner() put each result into the execution row's step_log and nowhere else, so a workflow could create a ticket and its very next action had no way to name it. "Raise a ticket, then email the requester about it" was unbuildable โ€” and unbuildable as two workflows too, because create_ticket dispatches no event of its own.

A successful action's result is now merged back into the payload under two names:

Merge code Reaches
{{last.ticket_id}} the action immediately before this one
{{steps.1.ticket_number}} a particular action, by the number shown beside it

last covers the common two-action chain; steps is what a third action needs, because by then last is the second action's result.

๐Ÿ”‘ Merged under their OWN keys, never into the payload's natural namespace. Writing a new ticket's id over ticket.id would silently repoint every later {{ticket.*}} on a ticket-triggered workflow at a different ticket from the one that fired it โ€” a workflow that had been correct for a year would quietly start emailing about the wrong record.

โš ๏ธ steps is one-based, matching the number the editor prints next to each action. A merge code that disagreed with the number on screen would be wrong in the one place people copy it from.

โš ๏ธ Nothing is merged on a dry run (nothing ran, so a later action's preview shows the merge code unresolved โ€” which is honest) or after a failed action (the chain stops there anyway).

Both prefixes are returned by variablePrefixes() for every trigger, since what an earlier action produced depends on the actions chosen, not on what fired the workflow.

One ticket, however a form raised it

FreeITSM had two implementations of "turn a form submission into a ticket", and they disagreed:

catalogueCreateTicketFromSubmission() action_create_ticket()
Requester resolved by users.id matched by email string, upserting
Company from the requester's tenant_id not set at all
Body answers as a fully-escaped table whatever the author typed
Reachable by approval only, no config any trigger, fully configurable

So the configurable path produced the worse ticket. action_create_ticket() now delegates to the shared path whenever the payload carries a submission.id, with the configured args applied on top as overrides.

๐Ÿ”‘ The rule is: an override always wins, and the submission fills in whatever was left blank. That single sentence is what lets one function serve the approval path (which configures nothing) and a form's own action list (which may configure everything).

โš ๏ธ A blank body now means "show me what they asked for" โ€” the escaped answer table โ€” rather than an empty ticket. That is a behaviour change for any existing form.submitted workflow that left the body empty, and a strict improvement.

โš ๏ธ The engine must lazily require_once includes/catalogue_approvals.php inside the handler. Both that file and includes/services/forms.php require the engine at file level, so a top-level require back would be circular. Same idiom as the notifications router and the search indexer.

Anything with no submission in its payload โ€” a fan-out from ticket.created, say โ€” keeps the original behaviour untouched.

Sending an email without a ticket

send_email took its mailbox from the ticket, so it could not acknowledge anything that was not yet a ticket. It now takes either a ticket or a mailbox_id, with an explicitly chosen mailbox winning over the ticket's own.

โš ๏ธ No ticket means no reply marker. buildTemplateEmailBody() appends "please reply above this line" over an [*** SDREF:โ€ฆ ***] token, which is what threads a reply back onto the ticket. With no ticket the token is empty, so the trailer would invite a reply that lands nowhere โ€” a standalone send uses plainEmailBody(), which is the same styling without the marker.

โš ๏ธ templateSaveSentEmail() is skipped without a ticket (there is no conversation to file the copy against), but emailLogSent() still records the send, so a standalone message is auditable in Email send log โ€” it just isn't attached to anything.

mailbox is an action lookup only, never a condition field: nothing dispatches a mailbox id in a payload, so there would be nothing to compare against.

Dry run

Dry run (toolbar, beside Test fire) evaluates the workflow's conditions for real, then describes each action instead of executing it.

The run is logged like any other but flagged is_dry_run, and each step records would_run (the action's catalogue label) plus would_args โ€” the args with {{variables}} already substituted. So you read the actual email subject, the actual chat message, the actual note. Not the template: the finished text.

Nothing is written, emailed, queued or sent โ€” no ticket mutations, no notes, no webhook_deliveries rows โ€” and the workflow's run_count / last_run_datetime are untouched. A dry run is not a run. Any signing secret is redacted rather than echoed back, so the debugging surface can't be used to read a secret out of a saved workflow.

That makes it safe to point at a live workflow, and it's the sensible first thing to do with a freshly cloned template.

Dry run vs Test fire. Test fire actually executes โ€” it will genuinely send that email. Dry run won't. The help page separates them explicitly, because the distinction matters the first time you point one at production.


Variable substitution (merge codes)

Any free-text action arg supports {{path.to.field}} placeholders, resolved against the dispatch payload at execution time via the engine's renderTemplate() helper.

You don't type them by hand. Every variable-friendly field has an Edit with variables button that opens a roomy editor with a searchable variable list beside it โ€” click one and it drops in at the cursor. A live preview shows the finished text with each code swapped for what it stands for.

The list is scoped to the trigger โ€” and that's load-bearing

renderTemplate() resolves an unknown path to an empty string. It does not error.

So a workflow on knowledge.published whose message says {{ticket.subject}} doesn't fail โ€” it quietly posts a blank where the subject should be, and nobody notices. There is no ticket in a knowledge event.

WorkflowEngine::availableVariables($trigger) is the catalogue of codes a given trigger can actually resolve. The picker offers only those, and a hand-typed code that can't resolve is flagged under the field ("will come out blank โ€” not available on this trigger").

This catalogue is deliberately distinct from availableFields(), which answers a different question โ€” "what can I write a condition on" (scalars only). {{event}} is a valid merge code and a useless condition field; {{ticket.full}} is a whole JSON object, meaningless in a comparison.

Use the name, not the id

{{ticket.priority_id}} renders as 4. That's fine for a receiving system that expects an id, and useless in a message a person reads โ€” nobody in your Slack channel knows what priority 4 is.

So every lookup field has a readable twin:

Code Renders
{{ticket.priority_id}} 4
{{ticket.priority_name}} Critical

The picker gives the name the plain label (Ticket ยท Priority) and marks the numeric one (id). Labelling them the other way round would hand the natural-sounding name to the field that renders a meaningless number.

The twins are derived from the same FIELD_LOOKUP_TABLES registry that powers the condition dropdowns โ€” so adding a new lookup field gets you its name code for free, with no second list to maintain. That includes {{old_priority_name}} / {{new_priority_name}} on the change triggers, so "escalated from High to Critical" writes itself.

Resolution happens once per run (enrichWithLookupNames(), cached), before conditions and actions โ€” so the step log, the dry-run preview and every action all see the same enriched payload. It never clobbers a value the host module set deliberately: form.submitted already ships a real form.name, and that wins over anything derived from form.id.

Quote the reference, not the row id

Same idea, one level up. {{ticket.id}} is the internal database id (83). The thing a requester quotes back at you โ€” and the thing that belongs in an email subject or a chat alert โ€” is the ticket number (ETC-623-64409).

Code Renders
{{ticket.id}} 83
{{ticket.number}} ETC-623-64409

Also {{problem.number}}. Changes and tasks have no reference column, so they're correctly offered none โ€” their id is their identity today.

Named number, not "ref": the app and the schema already call it a number (tickets.ticket_number), and a third word for the same concept is just a synonym to learn.

โš ๏ธ One exception, and it matters. A Ticket ID action argument must stay {{ticket.id}}. That field tells the engine which record to act on, and it looks it up by row id โ€” feed it a ticket number and the action fails. The rule: the number goes in the words a person reads; the id stays in the plumbing. The starter templates follow exactly this split.

It also offers:

  • {{event}} โ€” the name of the trigger that fired.
  • {{<object>.full}} โ€” e.g. {{ticket.full}}, the entire record as JSON, in the exact shape the REST API returns it (for objects the engine can hydrate whole).
  • {{submission.fields.<your form's label>}} โ€” an open-ended shape for form answers, which can't be enumerated because they're keyed by whatever the form author called them.

Labels are derived from the path (ticket.requester_email โ†’ "Ticket ยท Requester email") rather than hand-mapped, so they can't rot as the 138-trigger catalogue grows.

See Workflow & Webhook Pitfalls #1 and #2 โ€” the send_webhook action's own default body template shipped referencing {{event}}, which the engine never populated, so every custom webhook built from the default had been sending "event": "". That bug was found by building this picker.

Common variables for ticket triggers:

  • {{ticket.id}}, {{ticket.subject}}, {{ticket.priority_id}}, {{ticket.status_id}}
  • {{ticket.department_id}}, {{ticket.type_id}}, {{ticket.assigned_analyst_id}}
  • {{ticket.owner_id}}, {{ticket.created_by}}, {{ticket.requester_email}}
  • For ticket.status_changed / ticket.priority_changed: also {{old_status_id}}, {{new_status_id}}, {{old_priority_id}}, {{new_priority_id}}
  • For ticket.assigned: also {{analyst_id}}, {{team_id}}

In ticket_id fields, the default {{ticket.id}} is pre-filled โ€” leave it as-is so the workflow operates on whichever ticket triggered it. Specifying a literal id hard-codes the workflow to one ticket.

Key methods: WorkflowEngine::availableVariables($trigger) (the merge-code catalogue), variablePrefixes($trigger) (open-ended shapes like submission.fields.).

AI co-author

Toolbar button (amber-tinted, sparkle icon) opens a modal where the user describes the workflow in plain English. The backend (api/workflow/_ai_helpers.php + api/workflow/ai_compose.php) sends a system prompt that contains the live engine catalogues โ€” every trigger, condition operator, action, and the per-action arg schemas with the first 8 id=label pairs of every lookup table โ€” so the AI can only propose things the engine actually understands.

The model returns a JSON proposal that's validated server-side against the same catalogues. Unknown triggers fall back to the first known one, unknown operators / actions are dropped with warnings surfaced to the client. The modal shows the AI's explanation, a colour-coded preview of the proposed workflow, any warnings. Apply replaces the canvas nodes with the proposal; Discard lets the user try again. When the canvas already has content, the AI iterates on it โ€” "now only match Finance" / "add an action to log the ticket id too" edits what's there rather than starting over.

Provider-aware โ€” works with both Anthropic and OpenAI (and falls back to either if you have one of them configured). Settings page at workflow/settings/ lets you pick the provider, model, API key (encrypted, masked) and verify-SSL toggle per module, so installs can route different modules' AI calls to different accounts for granular billing.

Test fire

The Test fire button runs the workflow with a synthetic payload built from its own conditions โ€” the editor sets payload[field] = condition.value for each equality op (or sentinels for is_empty etc.), so the conditions trivially pass and the action path actually executes. The result lands in Recent runs on the workflow detail panel with status (success / failed / skipped) and full step log.

To test against real data, just do the thing that triggers the workflow โ€” assign a ticket, change its priority, etc.

A test fire is logged as a normal execution, but it doesn't increment the workflow's run_count counter โ€” so the production metrics stay clean however many times you test.

The execution log โ€” and why it has to exist

The engine deliberately swallows its own errors. A broken workflow must never break the ticket save that triggered it, so dispatch() catches everything and writes it to the execution row rather than letting it propagate.

That is the right call. It also means a workflow can fail on every single fire and nothing anywhere tells you.

Two things break that silence.

workflow/executions.php

Every run of every workflow. Before this, the only view of a run was the editor's Recent runs panel โ€” the last 20 runs of one workflow, no filters, no search, no history. That's the wrong shape for the question you actually ask when something breaks ("what failed, and why?"), because you don't yet know which workflow to open.

  • Filter by workflow, status, trigger, date range, dry-run vs real, or free text over the workflow name / trigger / error message.
  • Status tallies double as one-click filters โ€” the failure count is also the button that shows you the failures.
  • Filters live in the URL, so a view is bookmarkable and shareable. That's what lets the Watchtower card link straight to today's failures instead of dropping you at an unfiltered list.

Open a run and you get what the engine actually did:

Conditions Which matched and which didn't โ€” with the actual value it compared against. So a skipped run finally explains itself: `ticket.subject contains "urgent"
Actions What each one returned, or exactly how it failed. Dry runs show would_run + the resolved args.
Webhooks Any deliveries the run queued, with their status โ€” bridging the execution log and the delivery log, which are otherwise two separate hunts.
Trigger payload The snapshot captured at run time: exactly what the conditions were tested against and what the {{variables}} resolved from. Usually the single most useful thing on the page.

Runs stay attributable after their workflow is deleted โ€” the workflow name is snapshotted onto the row (workflow_name), so history survives and remains searchable by name even though workflow_id goes NULL.

The Watchtower card

The dashboard-level answer to the same problem. Over the last 24 hours:

  • Failed runs โ€” the workflow errored.
  • Aborted runs โ€” loop protection refused to run it (see Engine internals).
  • Dead-lettered webhooks โ€” and this one is easy to miss: the workflow succeeded. It queued the send. The message simply never arrived, after every retry. Nothing else in the product would ever surface that.

Red on failures or aborts, amber on dead webhooks alone, green when clear.

It doesn't just count โ€” it names the failing workflows and shows what the error said. "3 failures" sends you hunting; "Unknown status_id: 999999" usually answers the question where you're standing. Every line deep-links into the pre-filtered execution log.

Dry runs are excluded. A dry run is a person testing something, and its failures are expected โ€” not an incident.

Host module trigger wiring

Triggers are dispatched from host modules' save flows. The catalogue now spans every module (138+ triggers โ€” see the trigger catalogue notes above and the Webhooks event list): rich domain events like ticket.created, form.submitted, task.completed, change.approved, problem.created, knowledge.published, service_status.incident_created, morning_check.recorded, software.application_discovered and the project events (project.health_changed, project.tolerance_breached, project.stage_closed, project.stage_due, 3.2.0), plus generated .created/.updated/.deleted triggers for every reusable entity and settings lookup. The original four ticket triggers illustrate the wiring pattern:

Trigger Wired from Notes
ticket.created api/tickets/create_ticket.php After commit; reads back resolved status_id/priority_id so the payload carries real ids (the INSERT uses name-based subselects).
ticket.status_changed api/tickets/assign_ticket.php When the status_id changes. Payload carries the full post-update ticket state plus old_status_id / new_status_id.
ticket.priority_changed api/tickets/assign_ticket.php When the priority_id changes. Payload carries the full post-update ticket state plus old_priority_id / new_priority_id.
ticket.assigned api/tickets/assign_ticket.php When the assigned_analyst_id changes.

Each dispatch is wrapped in a defensive try/catch on the host-module side (engine already swallows internally โ€” belt+braces so an engine outage can't break the host module's response that's already been echoed). UI settings endpoints emit via the best-effort wf_emit() helper; shared services call the engine directly.

Engine internals

WorkflowEngine::dispatch($event, $payload)        โ† host modules call this
   โ†’ SELECT * FROM workflows WHERE trigger_event = ? AND is_active = 1
   โ†’ for each matching workflow:
       โ†’ insert workflow_executions row (status = 'running')
       โ†’ evaluate conditions (AND); if any fail, status = 'skipped'
       โ†’ else: foreach action, executeAction(type, args, payload)
           โ†’ renderTemplate() resolves {{vars}} in free-text args
           โ†’ handler (one of the 8) does the work
           โ†’ if it throws, mark step failed, stop the chain
       โ†’ update workflow_executions with final status + step_log
       โ†’ update workflows.last_run_* + run_count

Failures are isolated. A buggy workflow cannot break the host module's request. Engine-level errors go to PHP error_log; per-action errors go to the execution row's step_log; neither propagates.

Infinite-loop protection. Workflow actions mutate host data that re-dispatches events, so a workflow could trigger itself โ€” directly (A fires an event that re-runs A), via a cycle (A โ†’ B โ†’ A), or via fan-out. Because execution is synchronous within one request, the engine guards every run with three request-scoped counters: an active-workflow stack (a workflow already running in the current chain is refused re-entry โ€” catches every cycle), a chain-depth ceiling (MAX_CHAIN_DEPTH, the backstop for deep acyclic cascades), and a per-request run ceiling (MAX_RUNS_PER_REQUEST, the backstop for fan-out). A blocked run is recorded as an aborted execution row with the reason, so the block shows up in the audit โ€” and on the Watchtower card โ€” rather than failing silently.

Synchronous execution in v1 โ€” no job queue. Keep actions fast and idempotent. Async + retry land when the first slow external action (Microsoft Graph) requires them.

Key files

File Purpose
workflow/includes/engine.php The whole engine: dispatch / manualFire / 4 catalogue methods / 8 action handlers / variable substitution.
workflow/editor.php Canvas page. Exports engine catalogues + lookup values + action lookups to JS.
workflow/index.php List view (tickets-settings chrome).
workflow/settings/index.php Per-module AI settings tab (provider / model / key / Test).
workflow/help.php The user-facing help guide.
assets/js/workflow-editor.js Canvas state, render loop, drag/snap, selection, detail panel, action arg form renderer, AI co-author modal.
assets/css/workflow.css Canvas / node / detail-panel / status-pip / AI modal styling.
api/workflow/list.php / get.php / save.php / delete.php / fire.php List CRUD + Test fire.
api/workflow/_ai_helpers.php / ai_compose.php / get_ai_settings.php / save_ai_settings.php / test_ai_key.php AI co-author + per-module AI settings.

Key tables

Table Columns of note
workflows id, name, description, trigger_event, conditions (TEXT, JSON), actions (TEXT, JSON), is_active, created_by, last_run_datetime, last_run_status, run_count, created_datetime, updated_datetime
workflow_executions id, workflow_id, trigger_event, trigger_payload (TEXT, JSON), status (running / success / failed / skipped), step_log (TEXT, JSON โ€” per-step audit), error_message, started_datetime, finished_datetime

What's still ahead

  • Webhooks โ€” done, all phases: the send_webhook action, async delivery (queue, retries/backoff, dead-letter, delivery log with Replay), Send-test button, full-object payloads, the 138-trigger catalogue, and the overview dashboard. See Webhooks.
  • Teams / Slack messages โ€” done via the send_webhook action (Slack/Teams/Discord presets).
  • Dry-run mode โ€” done. See Dry run above.
  • Starter recipes โ€” done. See Starter templates above. (SLA-breach escalation and licence-renewal reminders are not in the shipped set โ€” neither has a wired trigger yet; see below.)
  • Tier 3 actions โ€” Microsoft Graph: graph_add_to_group, graph_assign_license, graph_disable_user. The new-starter / leaver automations that justify the module's existence vs paid ITSM suites. Reuses the existing tenant OAuth scaffolding from the mailbox integration.
  • SLA + contract-renewal triggers โ€” done. Four time-based triggers (sla.warning, sla.breached, contract.expiring, asset.warranty_expiring), a fire-once ledger, and the four starter templates that were previously impossible. See Time-based triggers above.
  • Cross-workflow execution log โ€” done. See The execution log above.
  • Watchtower integration โ€” done. Failed runs, loop-protection aborts and dead-lettered webhooks surface as an attention card.
  • Workflow versioning โ€” draft / publish / rollback.
  • Streaming AI co-author โ€” claude.ai-style live output as the canvas builds.

See also

  • Time-Based Triggers โ€” SLA, contract and warranty triggers: the crons they need, the fire-once ledger, and how to add one of your own.
  • Webhooks โ€” the outbound-webhook deep dive (formats, signing, delivery engine, data protection).
  • HTTPS Certificates & CA Bundles โ€” why unable to get local issuer certificate is almost never your webhook's fault.
  • Workflow & Webhook Pitfalls โ€” real bugs found in this code, what caused them, and the general shape of each.
โš ๏ธ **GitHub.com Fallback** โš ๏ธ