Skip to content

Task Workflows Developer Guide

Ed Mozley edited this page Nov 11, 2026 · 2 revisions

πŸ› οΈ Workflows for tasks - Developer Guide

How a workflow on a task trigger reaches the task's ticket, and the three actions that change a task. Built for #274 (5.0.0). User guide: Workflows β†’ Task workflows and their ticket. The engine as a whole: Workflow engine - Developer Guide.

The one-line version: every task event now carries task.ticket_id. The engine adds the ticket's number, subject and link at run time. A ticket action added to a task workflow starts with {{task.ticket_id}} instead of {{ticket.id}} (an arg's trigger_defaults, applied by the editor). Three new actions (set_task_status, assign_task, add_task_comment) go through TasksService as the workflow, so they keep every rule the screen has. A comment by a workflow is written by System, which needed task_comments.analyst_id to allow NULL.

The issue's example, "when a task is marked Done, add a note to its ticket", used to need a webhook out to n8n and back into the REST API. It is now a template: When a task is done, note it on its ticket.


1. The files

File 🎨 What changed
includes/services/tasks.php βš™οΈ every task dispatch carries ticket_id; taskTicketId(); createComment() stores NULL for "no person"
workflow/includes/engine.php βš™οΈ task.ticket_id on every task trigger; enrichWithTaskTicket(); the three merge codes; trigger_defaults; the no-ticket message; set_task_status, assign_task, add_task_comment; taskActionContext(), taskForAction(); ACTION_MODULES
workflow/includes/templates.php βš™οΈ the recipe task_done_note_on_ticket
assets/js/workflow-editor.js, workflow/editor.php (v24) πŸ–₯️ argDefault(); the starting value follows a trigger change
database/freeitsm.sql, includes/db_verify_schema.php, api/system/db_verify.php πŸ—„οΈ task_comments.analyst_id NULL, and the probe that relaxes it on existing installs
api/tasks/get.php, assets/js/tasks.js (v76) πŸ–₯️ a System comment is shown as System
includes/case_report.php, includes/case_report_page.php πŸ”— a System comment or note says FreeITSM, not "former analyst"
workflow/help.php, lang/*/workflow.php, lang/*/tasks.php πŸ–₯️ help rows 15-17, the callout, the recipe mention; tasks.detail.comment_by_system
includes/feature_bingo/cards/workflow.php πŸ”— workflow.task_ticket
tests/workflow-task-actions.php πŸ§ͺ section 9

2. The task's ticket in every task event

tasks.ticket_id already existed ("the ticket this task belongs to"), but no task event sent it, so {{task.ticket_id}} rendered empty. Every dispatch in TasksService now includes it. The shared helper for change events reads it with the rest of the row:

// includes/services/tasks.php
private static function taskEventDispatch(PDO $conn, string $event, int $taskId, array $extra = []): void
{
    try {
        $rb = $conn->prepare("SELECT title, status_id, priority_id, assigned_analyst_id, due_date, ticket_id FROM tasks WHERE id = ?");
        $rb->execute([$taskId]);
        $taskRow = $rb->fetch(PDO::FETCH_ASSOC) ?: [];
        WorkflowEngine::dispatch($event, [
            'task' => array_merge([
                'id'          => $taskId,
                'title'       => $taskRow['title'] ?? null,
                'status_id'   => isset($taskRow['status_id']) ? (int)$taskRow['status_id'] : null,
                'priority_id' => isset($taskRow['priority_id']) ? (int)$taskRow['priority_id'] : null,
                'assignee_id' => isset($taskRow['assigned_analyst_id']) ? (int)$taskRow['assigned_analyst_id'] : null,
                'due_date'    => $taskRow['due_date'] ?? null,
                'ticket_id'   => !empty($taskRow['ticket_id']) ? (int)$taskRow['ticket_id'] : null,   // GH #274
            ], $extra),
        ]);
    } catch (Exception $wfEx) {
        error_log('Workflow dispatch error in task service (' . $event . '): ' . $wfEx->getMessage());
    }
}
Event Where its ticket_id comes from
task.status_changed, task.due_date_changed, task.comment_added taskEventDispatch() above
task.created taskTicketId(), because the payload is built from the input rather than read back
task.assigned assignedDispatch()'s own read-back gains ticket_id
task.completed completedDispatch()'s read-back gains ticket_id
task.collaborator_added / _removed collaboratorDispatch()'s read-back gains ticket_id
task.deleted $row['ticket_id'] from the row loaded before the delete
/**
 * The ticket a task belongs to (tasks.ticket_id), or null - for task.created, whose payload
 * is built from the input rather than read back (GH #274).
 *
 * TRAP: it travels as task.ticket_id, never as a top-level `ticket` object. A `ticket` key
 * would make the event look like a ticket event: workflowEventTenantId() reads `ticket` first
 * (the task and its ticket can be in different companies), and the notification router and
 * search indexer both act on payload['ticket'].
 */
private static function taskTicketId(PDO $conn, int $taskId): ?int
{
    $s = $conn->prepare("SELECT ticket_id FROM tasks WHERE id = ?");
    $s->execute([$taskId]);
    $v = $s->fetchColumn();
    return $v ? (int)$v : null;
}

Why not a top-level ticket object

The tempting design was to add ticket: {id, number, …} to task payloads, so every ticket action's default {{ticket.id}} would just work. Three things read $payload['ticket'] and would have misbehaved:

// includes/workflow_tenancy.php - workflowEventRecordTables(): the FIRST record found decides the event's company
'ticket'         => ['tickets',            'default'],
'task'           => ['tasks',              'default'],
  • The company would be wrong. With a ticket key, a task event would take its ticket's company, and a company's own workflows would run (or not) for the wrong company.
  • The notification router (includes/notifications_router.php) reads $payload['ticket']['id'] and ['assigned_analyst_id'] to decide whom to tell.
  • The search indexer (includes/search/indexer.php) reindexes $payload['ticket']['id'] on every event.

So the ticket rides under task, and the editor changes the starting value instead (section 4).


3. Fields and merge codes

The condition field

// workflow/includes/engine.php - availableFields(), after $byTrigger is built
// GH #274: every task event carries the ticket the task belongs to (TasksService),
// so a task workflow can act on its ticket - or check it has one.
foreach ($byTrigger as $k => $f) {
    if (str_starts_with($k, 'task.') && !in_array('task.ticket_id', $f, true)) $byTrigger[$k][] = 'task.ticket_id';
}
private const FIELD_TYPES = [
    // …
    'task.id'                => 'numeric',
    'task.ticket_id'         => 'numeric',   // GH #274

The editor shows it as Task Β· Ticket (humaniseFieldPath() drops the _id). The recipe uses is_not_empty on it, so tasks without a ticket are skipped.

The ticket's number, subject and link

public static function enrichPayloadForTemplates(PDO $conn, array $payload): array
{
    $payload = self::enrichWithLookupNames($conn, $payload);
    $payload = self::enrichWithReferences($conn, $payload);
    $payload = self::enrichWithTaskTicket($conn, $payload);   // GH #274
    return self::enrichWithLinks($conn, $payload);
}

/**
 * GH #274: {{task.ticket_number}}, {{task.ticket_subject}} and {{task.ticket_url}} beside
 * {{task.ticket_id}}. Kept under `task` on purpose - see TasksService::taskTicketId().
 * Never clobbers; a missing ticket (deleted, or none) simply adds nothing.
 */
public static function enrichWithTaskTicket(PDO $conn, array $payload): array
{
    $id = self::dotGet($payload, 'task.ticket_id');
    if ($id === null || $id === '' || !is_numeric($id) || (int)$id <= 0) return $payload;
    try {
        $st = $conn->prepare('SELECT ticket_number, subject FROM tickets WHERE id = ?');
        $st->execute([(int)$id]);
        $t = $st->fetch(PDO::FETCH_ASSOC);
    } catch (Throwable) { $t = false; }
    if (!$t) return $payload;
    if (self::dotGet($payload, 'task.ticket_number') === null) self::dotSet($payload, 'task.ticket_number', (string)$t['ticket_number']);
    if (self::dotGet($payload, 'task.ticket_subject') === null) self::dotSet($payload, 'task.ticket_subject', (string)$t['subject']);
    if (self::dotGet($payload, 'task.ticket_url') === null) {
        try {
            require_once dirname(__DIR__, 2) . '/includes/public_url.php';
            self::dotSet($payload, 'task.ticket_url', rtrim(publicBaseUrl($conn), '/') . '/' . self::RECORD_LINKS['ticket'] . (int)$id);
        } catch (Throwable) { /* a missing link must never break a run */ }
    }
    return $payload;
}

enrichPayloadForTemplates() is the one entry point for both a real run and the editor's Send-test preview, so a preview can't drift from production. The codes are offered in the variable picker only for triggers that carry task.ticket_id:

// availableVariables()
if (in_array('task.ticket_id', $fields, true)) {
    $vars[] = ['path' => 'task.ticket_number',  'label' => 'Task Β· Ticket number',  'note' => 'The reference of the ticket this task belongs to, e.g. ETC-623-64409. Empty when the task has no ticket.'];
    $vars[] = ['path' => 'task.ticket_subject', 'label' => 'Task Β· Ticket subject', 'note' => 'The subject of the ticket this task belongs to.'];
    $vars[] = ['path' => 'task.ticket_url',     'label' => 'Task Β· Ticket link',    'note' => 'The full address of the task\'s ticket in FreeITSM.'];
}

4. Ticket actions start with the task's ticket: trigger_defaults

Every ticket action's ticket_id arg defaults to {{ticket.id}}, which is empty on a task trigger. Rather than make the author type {{task.ticket_id}}, an arg can say what it starts as on a given trigger family.

Server: tagged once, for every action

// availableActions() - the action list is built into $actions, then:
// GH #274: on a task trigger, a ticket-id arg starts as the TASK's ticket, so
// "task done -> note on its ticket" needs nothing typed. The editor applies it to
// new actions and swaps it when the trigger changes (workflow-editor.js).
foreach ($actions as &$a) {
    foreach ($a['args'] as &$arg) {
        if (is_array($arg) && ($arg['default'] ?? null) === '{{ticket.id}}') $arg['trigger_defaults'] = ['task.' => '{{task.ticket_id}}'];
    }
    unset($arg);
}
unset($a);
return $actions;

That catches every action with a {{ticket.id}} default (set_ticket_status, add_ticket_note, send_email, create_task, and so on), including ones added later, with no per-action change. The unset() after each by-reference foreach is required: without it, the next loop over the same variable writes through the dangling reference.

Editor: one function decides an arg's starting value

// assets/js/workflow-editor.js
/**
 * An arg's starting value. trigger_defaults (GH #274) win when the workflow's trigger
 * starts with one of their prefixes - a ticket action on a task trigger starts as
 * {{task.ticket_id}}; then new_default, for a NEWLY added action only (GH #268); then default.
 */
function argDefault(s, isNew) {
    if (!s || typeof s !== 'object') return undefined;
    if (s.trigger_defaults && triggerEvent) {
        for (const pre of Object.keys(s.trigger_defaults)) if (triggerEvent.startsWith(pre)) return s.trigger_defaults[pre];
    }
    if (isNew && s.new_default != null) return s.new_default;
    return s.default != null ? s.default : undefined;
}

It is used in four places: adding an action (addAction(), isNew true), changing an action's type (updateActionTypeFromDetail(), true), and drawing the arg form, both the shown value and the value seeded into n.args so it round-trips through save (false):

const shownDefault = argDefault(norm, false);
const currentVal = (argName in n.args) ? n.args[argName] : (shownDefault !== undefined ? shownDefault : '');
// …
if (!(argName in n.args) && shownDefault !== undefined) n.args[argName] = shownDefault;

Changing the trigger

function updateTriggerFromDetail() {
    triggerEvent = document.getElementById('wfTrigger').value;
    // GH #274: an arg still holding a starting value follows the new trigger -
    // {{ticket.id}} becomes {{task.ticket_id}} on a task trigger, and back. A value
    // somebody typed is left alone.
    nodes.forEach(n => {
        if (n.kind !== 'action' || !n.args) return;
        const def = (window.WF_ACTION_DEFS || {})[n.type] || {};
        Object.keys(def.args || {}).forEach(k => {
            const s = def.args[k];
            if (!s || typeof s !== 'object' || !s.trigger_defaults) return;
            const starts = [s.default].concat(Object.values(s.trigger_defaults));
            if (starts.includes(n.args[k])) n.args[k] = argDefault(s, false);
        });
    });
    // … redraw the open action's form and the nodes
}

Saved workflows are never rewritten. A value changes only in the editor, only when it still equals one of the starting values, and only when the person saves.


5. A ticket action on a task with no ticket

Without help, {{task.ticket_id}} renders empty and the action fails with ticket_id is required, which reads like a broken workflow. executeAction() says it plainly instead:

private static function executeAction(string $type, array $args, array $payload)
{
    // GH #274: a ticket action on a task workflow whose task has no ticket. Said plainly,
    // rather than "ticket_id is required", which reads like a broken workflow.
    if (isset($args['ticket_id']) && is_string($args['ticket_id']) && str_contains($args['ticket_id'], 'task.ticket_id')
        && trim(self::renderTemplate($args['ticket_id'], $payload)) === '' && $type !== 'create_task') {
        throw new Exception('This task is not linked to a ticket, so there is no ticket to act on. Add the condition "Task Β· Ticket" is not empty to run only for tasks that have one.');
    }
    switch ($type) {
        case 'set_task_status':     return self::action_set_task_status($args, $payload);    // GH #274
        case 'assign_task':         return self::action_assign_task($args, $payload);
        case 'add_task_comment':    return self::action_add_task_comment($args, $payload);
        // … every other action
    }
}

create_task is excluded: there ticket_id is optional ("link the new task to this ticket, if any").

Company scope is unchanged. Every ticket action already calls assertRecordInRunCompany($conn, 'tickets', $ticketId, 'ticket'), so a company's own workflow can't reach another company's ticket through a task. The check is on the ticket the action names, however it got there.


6. The three task actions

The definitions

$taskIdArg = [   // GH #274
    'type'          => 'text',
    'label'         => 'Task ID',
    'default'       => '{{task.id}}',
    'supports_vars' => true,
    'required'      => true,
];
// …
'set_task_status' => [
    'label'       => 'Set task status',
    'description' => 'Change a task\'s status. Moving it to a closed status completes it, exactly as on screen: …',
    'args'        => [
        'task_id'   => $taskIdArg,
        'status_id' => ['type' => 'lookup', 'label' => 'New status', 'lookup' => 'task_status', 'required' => true],
    ],
],
'assign_task' => [
    'label' => 'Assign task to analyst', 'description' => '…',
    'args'  => ['task_id' => $taskIdArg, 'analyst_id' => ['type' => 'lookup', 'label' => 'Assign to', 'lookup' => 'analyst', 'required' => true]],
],
'add_task_comment' => [
    'label' => 'Add a comment to the task', 'description' => '…',
    'args'  => ['task_id' => $taskIdArg, 'comment' => ['type' => 'textarea', 'label' => 'Comment', 'required' => true, 'supports_vars' => true]],
],
const ACTION_MODULES = [
    'create_task'      => 'tasks',
    'set_task_status'  => 'tasks',   // GH #274
    'assign_task'      => 'tasks',
    'add_task_comment' => 'tasks',
    // …
];

ACTION_MODULES hides an action from a new workflow while its module is switched off (GH #217). The task_status lookup reuses FIELD_LOOKUP_TABLES['task.status_id'] through ACTION_ARG_LOOKUPS, so the status dropdown is the same list the conditions use.

Who the action acts as, and which task

/**
 * Who a task action acts as: nobody - the workflow - recorded in the task's history
 * as "workflow". No company scope of its own: assertRecordInRunCompany() is the
 * boundary, as for tickets.
 */
private static function taskActionContext(): ActorContext
{
    require_once dirname(__DIR__, 2) . '/includes/service_context.php';
    require_once dirname(__DIR__, 2) . '/includes/services/tasks.php';
    $name = 'Workflow';
    if (self::$ctxWorkflowId) {
        try {
            $st = connectToDatabase()->prepare('SELECT name FROM workflows WHERE id = ?');
            $st->execute([(int)self::$ctxWorkflowId]);
            $n = $st->fetchColumn();
            if ($n) $name = 'Workflow: ' . $n;
        } catch (Throwable) { /* the plain name will do */ }
    }
    return new ActorContext(0, null, 'workflow', 'en', mb_substr($name, 0, 100));
}

/** The task the action names, checked to exist and to be in the run's company. */
private static function taskForAction(PDO $conn, array $args, array $payload): int
{
    $taskId = self::argInt($args, 'task_id', $payload);
    if (!$taskId) throw new Exception('task_id is required');
    $st = $conn->prepare('SELECT 1 FROM tasks WHERE id = ?');
    $st->execute([$taskId]);
    if (!$st->fetchColumn()) throw new Exception("Unknown task_id: {$taskId}");
    self::assertRecordInRunCompany($conn, 'tasks', $taskId, 'task');
    return $taskId;
}

ActorContext(0, null, 'workflow', …):

  • Actor 0 passes TasksService::assertCanWrite(), which only refuses a person with read-only Tasks access.
  • companyScope null means the service applies no company scope of its own. taskForAction() has already applied the workflow's company.
  • 'workflow' with a name makes TaskHistory::actor() record [null, 'workflow', 'Workflow: <name>']:
// includes/services/task_history.php
public static function actor(?ActorContext $ctx): array
{
    if ($ctx === null) return [null, 'system', null];
    if ($ctx->actorId > 0) {
        return [$ctx->actorId, $ctx->source === 'api' ? 'api' : 'analyst', $ctx->actorName !== '' ? $ctx->actorName : null];
    }
    $kind = in_array($ctx->source, ['portal', 'workflow', 'api'], true) ? $ctx->source : 'system';
    return [null, $kind, $ctx->actorName !== '' ? $ctx->actorName : null];
}

The handlers

private static function action_set_task_status(array $args, array $payload): array
{
    $conn = connectToDatabase();
    $taskId = self::taskForAction($conn, $args, $payload);
    $statusId = self::argInt($args, 'status_id', $payload);
    if (!$statusId) throw new Exception('status_id is required');
    $st = $conn->prepare('SELECT name FROM task_statuses WHERE id = ?');
    $st->execute([$statusId]);
    $name = $st->fetchColumn();
    if ($name === false) throw new Exception("Unknown status_id: {$statusId}");
    try {
        TasksService::saveTask($conn, self::taskActionContext(), ['id' => $taskId, 'status_id' => $statusId]);
    } catch (ServiceError $e) {
        throw new Exception($e->getMessage());   // the completion gate's refusal becomes the step's failure
    }
    return ['task_id' => $taskId, 'status_id' => $statusId, 'status_name' => (string)$name];
}

private static function action_assign_task(array $args, array $payload): array
{
    $conn = connectToDatabase();
    $taskId = self::taskForAction($conn, $args, $payload);
    $analystId = self::argInt($args, 'analyst_id', $payload);
    if (!$analystId) throw new Exception('analyst_id is required');
    self::assertAnalystReachesRunCompany($conn, $analystId);
    $st = $conn->prepare('SELECT full_name FROM analysts WHERE id = ? AND COALESCE(is_active, 1) = 1');
    $st->execute([$analystId]);
    $name = $st->fetchColumn();
    if ($name === false) throw new Exception("Unknown or inactive analyst_id: {$analystId}");
    try {
        TasksService::saveTask($conn, self::taskActionContext(), ['id' => $taskId, 'assigned_analyst_id' => $analystId]);
    } catch (ServiceError $e) {
        throw new Exception($e->getMessage());
    }
    return ['task_id' => $taskId, 'analyst_id' => $analystId, 'analyst_name' => (string)$name];
}

private static function action_add_task_comment(array $args, array $payload): array
{
    $conn = connectToDatabase();
    $taskId = self::taskForAction($conn, $args, $payload);
    $text = self::argString($args, 'comment', $payload);
    if (trim($text) === '') throw new Exception('comment is required');
    try {
        $id = TasksService::createComment($conn, self::taskActionContext(), $taskId, $text);
    } catch (ServiceError $e) {
        throw new Exception($e->getMessage());
    } catch (PDOException $e) {
        throw new Exception('Run System -> Database Verify so a workflow can write a comment with no author: ' . $e->getMessage());
    }
    return ['task_id' => $taskId, 'comment_id' => $id, 'comment_length' => mb_strlen($text)];
}

Why through TasksService, not an UPDATE. saveTask() is what the screen and the REST API use, so a workflow inherits all of it:

  • the completion gate (ChecklistsService::assertTaskCompletionAllowed()): required checklist steps and required task forms set to block refuse, and the step fails naming them;
  • completed_datetime;
  • the history;
  • the events task.status_changed / task.completed / task.assigned, so the assignee is told, other workflows run, and the calendar follows.

set_ticket_status, by contrast, writes tickets directly and has to call the closure gates by hand. That's exactly the kind of drift this avoids.

What each step returns is chained like every action's: {{last.status_name}}, or {{steps.2.comment_id}}.


7. A comment by System: task_comments.analyst_id NULL

createComment() inserted $ctx->actorId, and for a workflow that is 0, which fk_task_comments_analyst refuses. NULL now means System, as it already did for ticket notes (#281):

// includes/services/tasks.php - createComment()
// No person (a workflow - GH #274) is NULL: System. Never 0, which the foreign key refuses.
$conn->prepare("INSERT INTO task_comments (task_id, analyst_id, comment, created_datetime) VALUES (?, ?, ?, UTC_TIMESTAMP())")
     ->execute([$taskId, $ctx->actorId > 0 ? $ctx->actorId : null, $text]);

The schema, and relaxing it on existing installs

-- database/freeitsm.sql
CREATE TABLE IF NOT EXISTS `task_comments` (
    `id`                INT NOT NULL AUTO_INCREMENT,
    `task_id`           INT NOT NULL,
    `analyst_id`        INT NULL,   -- NULL = System: a workflow wrote it (GH #274)
    …
    CONSTRAINT `fk_task_comments_analyst` FOREIGN KEY (`analyst_id`) REFERENCES `analysts` (`id`)

Database Verify's column pass adds missing columns, but doesn't loosen existing ones, so api/system/db_verify.php has a probe beside the #281 one:

// `task_comments.analyst_id` was NOT NULL, so a comment no person wrote - the
// workflow action "Add a comment to the task" (GitHub #274) - had no honest value,
// and 0 is refused by `fk_task_comments_analyst`. NULL means System, as it does on
// ticket notes (#281). Relaxing it invalidates no existing row; safe to re-run.
try {
    $tcCol = $conn->prepare(
        "SELECT IS_NULLABLE FROM information_schema.columns
         WHERE table_schema = ? AND table_name = 'task_comments' AND column_name = 'analyst_id'"
    );
    $tcCol->execute([$dbName]);
    $tcRow = $tcCol->fetch(PDO::FETCH_ASSOC);
    if ($tcRow && strtoupper($tcRow['IS_NULLABLE']) === 'NO') {
        $conn->exec("ALTER TABLE `task_comments` MODIFY `analyst_id` INT NULL");
        $results[] = ['table' => 'task_comments', 'status' => 'updated',
                      'details' => ["analyst_id: NOT NULL β†’ NULL (a comment a workflow writes is System)"]];
    }
} catch (Exception $e) {
    // Non-fatal: comments keep working for people; only a workflow's comment is refused.
}

Until Database Verify runs, the action fails with a message saying so (the PDOException catch above), and comments by people keep working.

Every reader of task_comments

Reader Change
api/tasks/get.php (the task panel) JOIN β†’ LEFT JOIN, or a System comment vanishes from the panel; returns by_system
assets/js/tasks.js shows tasks.detail.comment_by_system (System) when there is no name
includes/case_report.php / case_report_page.php by_system β†’ FreeITSM (case-report.by_system), instead of "former analyst", which is for a deleted analyst. The same fix applies to ticket notes by System
api/v1/resources/tasks.php, task_history.php, evidence_export.php were already LEFT JOIN; the analyst is null
-- api/tasks/get.php
SELECT c.id, c.comment, c.created_datetime, a.full_name AS analyst_name, (c.analyst_id IS NULL) AS by_system
  FROM task_comments c
  LEFT JOIN analysts a ON c.analyst_id = a.id   -- LEFT: a System comment has no analyst (GH #274)
 WHERE c.task_id = ?
 ORDER BY c.created_datetime ASC
// assets/js/tasks.js - the comment list
<span class="comment-author">${esc(c.analyst_name || (Number(c.by_system) ? window.t('tasks.detail.comment_by_system') : ''))}</span>

8. The recipe

// workflow/includes/templates.php
'task_done_note_on_ticket' => [
    'name'        => 'When a task is done, note it on its ticket',
    'category'    => 'Tasks',
    'description' => 'When a task linked to a ticket is completed, writes an internal note on that ticket naming the task and who owned it - …',
    'trigger_event' => 'task.completed',
    'conditions'  => [
        ['field' => 'task.ticket_id', 'op' => 'is_not_empty', 'value' => ''],
    ],
    'actions'     => [
        [
            'type' => 'add_ticket_note',
            'args' => [
                'ticket_id' => '{{task.ticket_id}}',
                'note'      => "Task completed: {{task.title}} (owner: {{task.assignee_name}}).",
                'write_to'  => 'note',     // a real note by System (#268), not the history
                'shared'    => false,
            ],
        ],
    ],
],

{{task.assignee_name}} comes from enrichWithLookupNames(), which gives every lookup id a _name twin. As with every recipe, a cloned workflow starts inactive.


9. Loops

A workflow on task.status_changed that sets a task status, or on task.comment_added that adds a comment, would trigger itself. Section 6 deliberately goes through the service, which dispatches those events. The engine's existing guards stop the loop:

// workflow/includes/engine.php
//   1. $activeWorkflowIds β€” the stack of workflow ids currently running.
//      A workflow already on the stack is refused re-entry. …
//   2. MAX_CHAIN_DEPTH β€” a hard ceiling on nesting depth (10).
//   3. MAX_RUNS_PER_REQUEST β€” a ceiling on total runs in one request (100).

So the second run of the same workflow is refused, and recorded as an aborted execution so the author can see why. A task ↔ ticket cycle (task done β†’ note on ticket β†’ a ticket workflow that changes the task β†’ …) is stopped the same way: it must revisit a workflow still on the stack.


10. Tests

tests/workflow-task-actions.php runs on the throwaway database config.php uses, with Database Verify first. 23 checks. It runs the issue's example end to end with a real, active workflow, then each action through WorkflowEngine::runActionList():

// The example: a real workflow, fired by completing a ZZ task through TasksService
$conn->prepare("INSERT INTO workflows (name, trigger_event, conditions, actions, is_active) VALUES (?, 'task.completed', ?, ?, 1)")->execute([
    "$tag sample",
    json_encode($tpl['conditions']),
    json_encode([['type' => 'add_ticket_note', 'args' => ['ticket_id' => '{{task.ticket_id}}',
                  'note' => "$tag done: {{task.title}} on {{task.ticket_number}}", 'write_to' => 'note']]]),
]);
TasksService::saveTask($conn, $ctx, ['id' => $linked, 'status_id' => $closed]);
// β†’ a ticket note "ZZ274-… done: ZZ274-… linked on <ticket number>", internal, analyst_id NULL
TasksService::saveTask($conn, $ctx, ['id' => $unlinked, 'status_id' => $closed]);
// β†’ no note; the execution is recorded as 'skipped'
Area Proves
Designer task.ticket_id on task triggers; the three merge codes offered; ticket actions carry trigger_defaults for task., and keep {{ticket.id}} as their default; the three actions exist; the recipe's trigger and action are real; enrichWithTaskTicket() fills number, subject and link
The example the linked task's completion writes the note with the title and ticket number, internal, by System; the unlinked task is skipped by the condition and recorded as skipped
No ticket a ticket action on a task with no ticket fails with not linked to a ticket
set_task_status the task moves; the history says workflow and names it; a required task form set to block stops completion, and the step names the form
assign_task the owner changes; an unknown analyst fails the step
add_task_comment the comment is written with merge codes, analyst_id NULL; Database Verify made the column nullable; an unknown task fails
FREEITSM_TEST_DATABASE=<the throwaway db config.php uses> php tests/workflow-task-actions.php

Also run after it: workflow-ticket-note, workflow-company-scope, workflow-set-ticket-field, task-forms, task-drag-notifies, case-report, analyst-lifecycle.

Browser check (headless Chrome on a test install): in the editor, on trigger task.completed, a new Add a note to the ticket action shows {{task.ticket_id}} and a new Set task status shows {{task.id}}. On ticket.created, a new ticket action shows {{ticket.id}}.


Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally