Task Workflows Developer Guide - edmozley/freeitsm GitHub Wiki
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.
| 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 |
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;
}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
ticketkey, 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).
// 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 #274The 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.
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.'];
}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.
// 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.
// 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;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.
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.
$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 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. -
companyScopenull means the service applies no company scope of its own.taskForAction()has already applied the workflow's company. -
'workflow'with a name makesTaskHistory::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];
}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}}.
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]);-- 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.
| 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>// 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.
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.
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}}.