@open-mercato/core 0.10.1-develop.7728.1.6b52eb2849 → 0.10.1-develop.7741.1.61a2652671

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/api_key/index.js +2 -0
  3. package/dist/generated/entities/api_key/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/api_keys/data/entities.js +7 -0
  7. package/dist/modules/api_keys/data/entities.js.map +2 -2
  8. package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js +30 -0
  9. package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js.map +7 -0
  10. package/dist/modules/api_keys/services/apiKeyService.js +31 -9
  11. package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
  12. package/dist/modules/attachments/lib/ocrService.js +8 -0
  13. package/dist/modules/attachments/lib/ocrService.js.map +2 -2
  14. package/dist/modules/auth/api/logout.js +3 -1
  15. package/dist/modules/auth/api/logout.js.map +2 -2
  16. package/dist/modules/sales/commands/documents.js +1190 -1408
  17. package/dist/modules/sales/commands/documents.js.map +2 -2
  18. package/dist/modules/sales/commands/returns.js +34 -11
  19. package/dist/modules/sales/commands/returns.js.map +2 -2
  20. package/dist/modules/sales/commands/shared.js +5 -0
  21. package/dist/modules/sales/commands/shared.js.map +2 -2
  22. package/dist/modules/sales/commands/shipments.js +9 -0
  23. package/dist/modules/sales/commands/shipments.js.map +2 -2
  24. package/dist/modules/staff/api/timesheets/time-projects/route.js +6 -2
  25. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  26. package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js +1 -1
  27. package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js.map +2 -2
  28. package/dist/modules/workflows/api/tasks/[id]/complete/route.js +7 -1
  29. package/dist/modules/workflows/api/tasks/[id]/complete/route.js.map +2 -2
  30. package/dist/modules/workflows/backend/tasks/[id]/page.js +2 -1
  31. package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
  32. package/dist/modules/workflows/lib/instance-status.js +16 -0
  33. package/dist/modules/workflows/lib/instance-status.js.map +7 -0
  34. package/dist/modules/workflows/lib/task-handler.js +16 -8
  35. package/dist/modules/workflows/lib/task-handler.js.map +2 -2
  36. package/dist/modules/workflows/lib/task-sla.js +10 -0
  37. package/dist/modules/workflows/lib/task-sla.js.map +2 -2
  38. package/dist/modules/workflows/lib/workflow-executor.js +29 -1
  39. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  40. package/generated/entities/api_key/index.ts +1 -0
  41. package/generated/entity-fields-registry.ts +1 -0
  42. package/package.json +7 -7
  43. package/src/modules/api_keys/data/entities.ts +25 -1
  44. package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +234 -68
  45. package/src/modules/api_keys/migrations/Migration20261012202110_api_keys.ts +47 -0
  46. package/src/modules/api_keys/services/apiKeyService.ts +55 -9
  47. package/src/modules/attachments/lib/ocrService.ts +8 -0
  48. package/src/modules/auth/api/logout.ts +3 -1
  49. package/src/modules/auth/i18n/de.json +3 -0
  50. package/src/modules/auth/i18n/en.json +3 -0
  51. package/src/modules/auth/i18n/es.json +3 -0
  52. package/src/modules/auth/i18n/ko.json +3 -0
  53. package/src/modules/auth/i18n/pl.json +3 -0
  54. package/src/modules/sales/commands/documents.ts +1501 -1626
  55. package/src/modules/sales/commands/returns.ts +41 -14
  56. package/src/modules/sales/commands/shared.ts +40 -0
  57. package/src/modules/sales/commands/shipments.ts +10 -0
  58. package/src/modules/staff/api/timesheets/time-projects/route.ts +6 -2
  59. package/src/modules/workflows/api/portal/tasks/[id]/complete/route.ts +5 -1
  60. package/src/modules/workflows/api/tasks/[id]/complete/route.ts +7 -1
  61. package/src/modules/workflows/backend/tasks/[id]/page.tsx +6 -2
  62. package/src/modules/workflows/i18n/de.json +2 -0
  63. package/src/modules/workflows/i18n/en.json +2 -0
  64. package/src/modules/workflows/i18n/es.json +2 -0
  65. package/src/modules/workflows/i18n/ko.json +2 -0
  66. package/src/modules/workflows/i18n/pl.json +2 -0
  67. package/src/modules/workflows/lib/instance-status.ts +29 -0
  68. package/src/modules/workflows/lib/task-handler.ts +18 -10
  69. package/src/modules/workflows/lib/task-sla.ts +13 -0
  70. package/src/modules/workflows/lib/workflow-executor.ts +44 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/workflows/lib/task-handler.ts"],
4
- "sourcesContent": ["/**\n * Workflows Module - User Task Handler Service\n *\n * Handles user task lifecycle operations:\n * - Completing user tasks\n * - Claiming tasks from role queues\n * - Reassigning tasks\n * - Escalating overdue tasks\n *\n * Functional API (no classes) following Open Mercato conventions.\n */\n\nimport { EntityManager } from '@mikro-orm/core'\nimport type { AwilixContainer } from 'awilix'\nimport {\n UserTask,\n WorkflowInstance,\n WorkflowEvent,\n StepInstance,\n} from '../data/entities'\nimport { executeWorkflow } from './workflow-executor'\nimport { findDefinitionForInstance } from './find-definition'\nimport {\n findTaskDecision,\n findTaskDecisionTransition,\n withRecordedDecision,\n} from './task-decisions'\nimport { loadTaskDecisionContext } from './task-decision-context'\nimport { readRequiredFormFields } from './task-form-schema'\nimport * as stepHandler from './step-handler'\nimport * as transitionHandler from './transition-handler'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('workflows')\n\n// ============================================================================\n// Types and Interfaces\n// ============================================================================\n\n/**\n * Tenant/organization the caller is acting within. Every task lookup filters on\n * it, so a task id from another tenant resolves to \"not found\" instead of being\n * mutated.\n */\nexport interface UserTaskScope {\n tenantId: string\n organizationId: string\n}\n\n/**\n * Shape of the `taskHandler` DI registration (`di.ts`).\n *\n * The functions below stay exported \u2014 third-party modules may import them and\n * `BACKWARD_COMPATIBILITY.md` \u00A73 freezes import paths \u2014 but callers inside the\n * platform MUST resolve this service instead, per the module's first rule.\n */\nexport interface TaskHandlerService {\n completeUserTask: typeof completeUserTask\n claimUserTask: typeof claimUserTask\n releaseUserTask: typeof releaseUserTask\n reassignUserTask: typeof reassignUserTask\n}\n\nexport interface CompleteUserTaskOptions {\n taskId: string\n formData: Record<string, any>\n userId: string\n comments?: string\n /**\n * The decision button the assignee pressed, when the step authored any.\n *\n * It does two things: it SELECTS the outgoing route (the decision's\n * `transitionId` wins over the first-valid-transition default), and it is\n * RECORDED as part of the completion payload. Omitted, completion behaves\n * exactly as it always did.\n */\n decisionId?: string\n scope: UserTaskScope\n}\n\nexport class UserTaskError extends Error {\n constructor(\n message: string,\n public code: string,\n public details?: any\n ) {\n super(message)\n this.name = 'UserTaskError'\n }\n}\n\n// ============================================================================\n// User Task Completion\n// ============================================================================\n\n/**\n * Complete a user task and resume workflow execution\n *\n * This function:\n * 1. Validates the task exists and can be completed\n * 2. Updates the task with form data and completion info\n * 3. Merges form data into workflow context\n * 4. Logs completion event\n * 5. Resumes workflow execution\n *\n * @param em - Entity manager\n * @param container - DI container for workflow execution\n * @param options - Task completion options\n * @throws UserTaskError if task not found or validation fails\n */\nexport async function completeUserTask(\n em: EntityManager,\n container: AwilixContainer,\n options: CompleteUserTaskOptions\n): Promise<void> {\n const { taskId, formData, userId, comments, decisionId, scope } = options\n\n // Fetch task\n const task = await em.findOne(UserTask, {\n id: taskId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n status: { $in: ['PENDING', 'IN_PROGRESS'] },\n })\n\n if (!task) {\n throw new UserTaskError(\n 'Task not found or already completed',\n 'TASK_NOT_FOUND',\n { taskId }\n )\n }\n\n // A task addressed to one person is that person's to finish. Tasks that name\n // nobody \u2014 agent dispositions, role queues nobody has claimed \u2014 stay open to\n // anyone the route's feature gate already admitted, so this narrows the\n // over-permissive case without stranding the unassigned ones.\n const claimant = task.claimedBy ?? task.assignedTo\n if (claimant && claimant !== userId) {\n throw new UserTaskError(\n 'Task is assigned to another user',\n 'TASK_ASSIGNED_TO_ANOTHER_USER',\n { taskId, assignedTo: claimant }\n )\n }\n\n // Validate form data against schema (simple validation for MVP)\n // In Phase 7, we'll add comprehensive JSON Schema validation\n if (task.formSchema) {\n try {\n validateFormData(formData, task.formSchema)\n } catch (error) {\n throw new UserTaskError(\n error instanceof Error ? error.message : 'Form validation failed',\n 'FORM_VALIDATION_FAILED',\n { taskId, formSchema: task.formSchema, formData }\n )\n }\n }\n\n // Resolve the pressed decision BEFORE anything is mutated: an unknown\n // decision id must leave the task untouched rather than complete it down the\n // default route. The list is re-resolved from the instance's pinned\n // definition, so it is exactly the list the assignee was shown.\n const chosenDecision = decisionId\n ? findTaskDecision((await loadTaskDecisionContext(em, task)).decisions, decisionId)\n : null\n\n if (decisionId && !chosenDecision) {\n throw new UserTaskError(\n 'Unknown decision for this task',\n 'UNKNOWN_DECISION',\n { taskId, decisionId }\n )\n }\n\n // The chosen decision rides along inside the completion payload the task\n // already persists \u2014 no new column, and downstream route conditions can read\n // it once the payload is merged into the run context.\n const completionData = withRecordedDecision(formData, chosenDecision?.id)\n\n // Update task\n const now = new Date()\n task.status = 'COMPLETED'\n task.formData = completionData\n task.completedBy = userId\n task.completedAt = now\n task.comments = comments || null\n task.updatedAt = now\n\n await em.flush()\n\n // Fetch workflow instance\n const instance = await em.findOne(WorkflowInstance, task.workflowInstanceId)\n if (!instance) {\n throw new UserTaskError(\n 'Workflow instance not found',\n 'INSTANCE_NOT_FOUND',\n { workflowInstanceId: task.workflowInstanceId }\n )\n }\n\n // Branch-scoped completion: when the task belongs to a parallel branch,\n // merge form data into the branch namespace and resume just that branch.\n //\n // The decision is RECORDED here exactly as it is on the main path, but it does\n // not select the route: a branch advances through `resumeBranch` with its own\n // token, and overriding a branch's outgoing route is a parallel-execution\n // change rather than a task-surface one.\n if (task.branchInstanceId) {\n await logWorkflowEvent(em, {\n workflowInstanceId: instance.id,\n stepInstanceId: task.stepInstanceId,\n branchInstanceId: task.branchInstanceId,\n eventType: 'USER_TASK_COMPLETED',\n eventData: {\n taskId: task.id,\n taskName: task.taskName,\n completedBy: userId,\n formData: completionData,\n ...(chosenDecision ? { decisionId: chosenDecision.id } : {}),\n },\n userId,\n tenantId: instance.tenantId,\n organizationId: instance.organizationId,\n })\n\n const { resumeBranch } = await import('./parallel-handler')\n const resumed = await resumeBranch(em, {\n instanceId: instance.id,\n branchInstanceId: task.branchInstanceId,\n tenantId: instance.tenantId,\n organizationId: instance.organizationId,\n contextMerge: completionData,\n exitStepInstanceId: task.stepInstanceId,\n exitOutput: { userTaskId: task.id, formData: completionData },\n })\n\n if (resumed) {\n await executeWorkflow(em, container, instance.id, { userId })\n }\n return\n }\n\n // Merge form data into workflow context\n instance.context = {\n ...instance.context,\n ...completionData,\n }\n instance.updatedAt = now\n\n // Log USER_TASK_COMPLETED event\n await logWorkflowEvent(em, {\n workflowInstanceId: instance.id,\n stepInstanceId: task.stepInstanceId,\n eventType: 'USER_TASK_COMPLETED',\n eventData: {\n taskId: task.id,\n taskName: task.taskName,\n completedBy: userId,\n formData: completionData,\n ...(chosenDecision ? { decisionId: chosenDecision.id } : {}),\n },\n userId,\n tenantId: instance.tenantId,\n organizationId: instance.organizationId,\n })\n\n // Mark the step instance as completed\n const stepInstance = await em.findOne(StepInstance, {\n id: task.stepInstanceId,\n status: 'ACTIVE',\n })\n\n if (stepInstance) {\n await stepHandler.exitStep(em, stepInstance, { userTaskId: task.id, formData: completionData })\n }\n\n // Find the next automatic transition from the current step\n const currentStepId = instance.currentStepId\n\n // Load workflow definition to find transitions\n const definition = await findDefinitionForInstance(em, instance)\n\n if (!definition) {\n throw new UserTaskError(\n 'Workflow definition not found',\n 'DEFINITION_NOT_FOUND',\n { definitionId: instance.definitionId }\n )\n }\n\n const transitionContext = {\n workflowContext: instance.context,\n userId,\n }\n\n // A decision names the route to take. It is bound to the transition's durable\n // id, so it survives every route reorder, and it is looked up among the routes\n // LEAVING this step only \u2014 a button can never send the run down a route that\n // does not start where the run currently is. A decision whose route no longer\n // exists there falls through to the default evaluation rather than stranding\n // the run at a step it already completed.\n const decisionTransition = chosenDecision\n ? findTaskDecisionTransition(\n definition.definition.transitions || [],\n chosenDecision.transitionId,\n currentStepId\n )\n : null\n\n if (chosenDecision && !decisionTransition) {\n logger.warn('Task decision route not found on the current step; falling back to auto routing', {\n component: 'task-handler',\n taskId: task.id,\n decisionId: chosenDecision.id,\n transitionId: chosenDecision.transitionId,\n currentStepId,\n })\n }\n\n let selectedTransition = decisionTransition ?? null\n let targetStepId = selectedTransition?.toStepId ?? null\n\n if (!targetStepId) {\n // Find automatic transitions from current step\n const autoTransitions = (definition.definition.transitions || []).filter(\n (t: any) => t.fromStepId === currentStepId && t.trigger === 'auto'\n )\n\n if (autoTransitions.length === 0) {\n // No automatic transitions, workflow stays paused at current step\n return\n }\n\n // Find valid transitions using transition handler\n const validTransitions = await transitionHandler.findValidTransitions(\n em,\n instance,\n currentStepId,\n transitionContext\n )\n\n const firstValidTransition = validTransitions.find(t => t.isValid)\n\n if (!firstValidTransition || !firstValidTransition.transition) {\n // Resume workflow execution anyway, maybe conditions will be met later\n instance.status = 'RUNNING'\n await em.flush()\n return\n }\n\n targetStepId = firstValidTransition.transition.toStepId\n selectedTransition = firstValidTransition.transition\n }\n\n // Resume from the paused wait BEFORE executing the transition, exactly as\n // `sendSignal` does. Without it the executor's defense-in-depth PAUSED check\n // stops the run at the first step the completion traverses, so the instance\n // sits there as PAUSED and never reaches END. Guarded on PAUSED so completing\n // a stale task can never resurrect a CANCELLED or terminal run.\n if (instance.status === 'PAUSED') {\n instance.status = 'RUNNING'\n await em.flush()\n }\n\n // Execute the transition to move to next step\n\n const transitionResult = await transitionHandler.executeTransition(\n em,\n container,\n instance,\n currentStepId,\n targetStepId,\n { ...transitionContext, transitionId: selectedTransition?.transitionId },\n )\n\n if (!transitionResult.success) {\n logger.error('Transition failed', { component: 'task-handler', err: transitionResult.error })\n // Don't throw, just leave workflow in current state\n return\n }\n\n // Now continue workflow execution from the new step\n await executeWorkflow(em, container, instance.id, { userId })\n}\n\n/**\n * Whether the caller belongs to at least one of the task's queues.\n *\n * Exact string equality on role NAMES \u2014 no case folding and no trimming, because\n * the names on both sides come from the same tenant role table and normalizing\n * one side would let `Approver` claim an `approver` queue that RBAC treats as a\n * different role.\n */\nfunction hasRoleOverlap(\n taskRoles: readonly string[],\n callerRoleNames: readonly string[],\n): boolean {\n if (callerRoleNames.length === 0) return false\n const held = new Set(callerRoleNames)\n return taskRoles.some((role) => held.has(role))\n}\n\n/**\n * Claim a user task from a role queue\n *\n * Allows a user to claim a task that's assigned to their role(s).\n *\n * The claim itself is a COMPARE-AND-SET, not a read-then-write: the lookup\n * below only decides which error to report, while the row is taken with a\n * conditional `UPDATE \u2026 WHERE status = 'PENDING' AND claimed_by IS NULL`. Two\n * callers racing for the same task \u2014 which is exactly what the work inbox's\n * claim-next affordance produces \u2014 therefore cannot both win; the loser sees\n * zero affected rows and the same `TASK_NOT_FOUND` it would have seen a moment\n * later. Reading the status and flushing the entity afterwards would have let\n * both transactions read `PENDING` and both write.\n *\n * Membership in the queue is a REQUIREMENT, not a hint: a caller who holds none\n * of `assignedToRoles` is refused with the same `TASK_NOT_FOUND` a nonexistent\n * id produces, so a queue the caller does not belong to is indistinguishable\n * from a task that is not there. Holding `workflows.tasks.claim` admits you to\n * the endpoint; it does not admit you to somebody else's queue.\n *\n * `assignedToRoles` and `callerRoleNames` are both role NAMES \u2014 the Studio\n * picker writes names, the engine copies them onto the task, and `auth.roles`\n * carries names. A role RENAME therefore orphans existing assignments; moving\n * the comparison onto immutable role ids is a coordinated data + authored-\n * definition migration and is deliberately not done here.\n *\n * @param em - Entity manager\n * @param taskId - Task ID to claim\n * @param userId - User claiming the task\n * @param scope - Tenant/organization the caller acts within; both the lookup and\n * the conditional update filter on it, so a task belonging to another tenant\n * is never reachable and never writable\n * @param callerRoleNames - Server-derived role names of the caller (`auth.roles`),\n * never client-supplied. Required rather than optional so a caller that cannot\n * supply them fails to compile instead of silently claiming any queue.\n * @throws UserTaskError if task cannot be claimed\n */\nexport async function claimUserTask(\n em: EntityManager,\n taskId: string,\n userId: string,\n scope: UserTaskScope,\n callerRoleNames: readonly string[]\n): Promise<void> {\n const task = await em.findOne(UserTask, {\n id: taskId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n status: 'PENDING',\n })\n\n if (!task) {\n throw new UserTaskError(\n 'Task not found or already claimed',\n 'TASK_NOT_FOUND',\n { taskId }\n )\n }\n\n if (task.assignedTo) {\n throw new UserTaskError(\n 'Task is already assigned to a specific user',\n 'TASK_ALREADY_ASSIGNED',\n { taskId, assignedTo: task.assignedTo }\n )\n }\n\n if (!task.assignedToRoles || task.assignedToRoles.length === 0) {\n throw new UserTaskError(\n 'Task is not assigned to any roles',\n 'TASK_NOT_ROLE_ASSIGNED',\n { taskId }\n )\n }\n\n if (!hasRoleOverlap(task.assignedToRoles, callerRoleNames)) {\n throw new UserTaskError(\n 'Task not found or already claimed',\n 'TASK_NOT_FOUND',\n { taskId }\n )\n }\n\n const now = new Date()\n const claimed = await em.nativeUpdate(\n UserTask,\n {\n id: taskId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n status: 'PENDING',\n claimedBy: null,\n },\n {\n claimedBy: userId,\n claimedAt: now,\n status: 'IN_PROGRESS',\n updatedAt: now,\n },\n )\n\n if (claimed === 0) {\n throw new UserTaskError(\n 'Task not found or already claimed',\n 'TASK_NOT_FOUND',\n { taskId }\n )\n }\n\n // The conditional update above already wrote the row; mirroring it onto the\n // managed entity keeps the caller's identity-mapped copy from reporting the\n // pre-claim state back to the client.\n task.claimedBy = userId\n task.claimedAt = now\n task.status = 'IN_PROGRESS'\n task.updatedAt = now\n\n await em.flush()\n\n // Log event\n const instance = await em.findOne(WorkflowInstance, task.workflowInstanceId)\n if (instance) {\n await logWorkflowEvent(em, {\n workflowInstanceId: instance.id,\n stepInstanceId: task.stepInstanceId,\n eventType: 'USER_TASK_STARTED',\n eventData: {\n taskId: task.id,\n taskName: task.taskName,\n claimedBy: userId,\n },\n userId,\n tenantId: instance.tenantId,\n organizationId: instance.organizationId,\n })\n }\n}\n\n/**\n * Release a claimed task back to its role queue.\n *\n * The counterpart of `claimUserTask`, and deliberately its mirror image: the\n * write is the same compare-and-set, guarded on the caller still being the\n * claimant, so a task reassigned or completed in the meantime is refused rather\n * than silently reopened. It uses the EXISTING statuses \u2014 the row goes back to\n * `PENDING` \u2014 because adding a `UserTaskStatus` value is an Ask-First\n * state-machine change and a released task is exactly as pending as it was\n * before anyone claimed it.\n *\n * @throws UserTaskError if the task is not the caller's to release\n */\nexport async function releaseUserTask(\n em: EntityManager,\n taskId: string,\n userId: string,\n scope: UserTaskScope\n): Promise<void> {\n const task = await em.findOne(UserTask, {\n id: taskId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n status: 'IN_PROGRESS',\n })\n\n if (!task) {\n throw new UserTaskError(\n 'Task not found or not claimed',\n 'TASK_NOT_FOUND',\n { taskId }\n )\n }\n\n if (task.claimedBy !== userId) {\n throw new UserTaskError(\n 'Task is claimed by another user',\n 'TASK_ASSIGNED_TO_ANOTHER_USER',\n { taskId, claimedBy: task.claimedBy }\n )\n }\n\n const now = new Date()\n const released = await em.nativeUpdate(\n UserTask,\n {\n id: taskId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n status: 'IN_PROGRESS',\n claimedBy: userId,\n },\n {\n claimedBy: null,\n claimedAt: null,\n status: 'PENDING',\n updatedAt: now,\n },\n )\n\n if (released === 0) {\n throw new UserTaskError(\n 'Task not found or not claimed',\n 'TASK_NOT_FOUND',\n { taskId }\n )\n }\n\n task.claimedBy = null\n task.claimedAt = null\n task.status = 'PENDING'\n task.updatedAt = now\n\n await em.flush()\n\n const instance = await em.findOne(WorkflowInstance, task.workflowInstanceId)\n if (instance) {\n await logWorkflowEvent(em, {\n workflowInstanceId: instance.id,\n stepInstanceId: task.stepInstanceId,\n eventType: 'USER_TASK_RELEASED',\n eventData: {\n taskId: task.id,\n taskName: task.taskName,\n releasedBy: userId,\n },\n userId,\n tenantId: instance.tenantId,\n organizationId: instance.organizationId,\n })\n }\n}\n\n// ============================================================================\n// User Task Reassignment\n// ============================================================================\n\n/**\n * The new owner of a task, as the reassign route resolved it.\n *\n * At least one of the two must be non-empty, and the route enforces it: the\n * shape \u00A76.4 makes uncompletable is precisely \"no assignee AND no role queue\",\n * so a reassignment that produced it would be the one write that can create the\n * problem reassignment exists to fix.\n */\nexport interface ReassignUserTaskTarget {\n assignedTo?: string | null\n assignedToRoles?: readonly string[] | null\n}\n\nexport interface ReassignUserTaskOptions {\n taskId: string\n /** Who performed the reassignment \u2014 recorded on `reassigned_by`. */\n userId: string\n target: ReassignUserTaskTarget\n reason?: string | null\n scope: UserTaskScope\n}\n\n/**\n * Statuses a task can be reassigned in.\n *\n * The same pair `completeUserTask` accepts: moving a COMPLETED or CANCELLED row\n * to somebody else records an audit trail for work that can never be done, and\n * the assignee would be handed a task whose Complete button always fails.\n */\nconst REASSIGNABLE_STATUSES: readonly string[] = ['PENDING', 'IN_PROGRESS']\n\n/**\n * Move a task to a different assignee or role queue, with an audit trail.\n *\n * This is the ACT half of the \u00A76.4 model's central asymmetry: administration\n * widens seeing, never acting, so an administrator who can see a colleague's\n * task \u2014 or a task that belongs to nobody at all \u2014 takes it first and the taking\n * is permanently recorded. It is also the documented remedy for the one shape\n * the model leaves uncompletable (no assignee, no claim, no role queue), which\n * is why it deliberately accepts a task in exactly that state.\n *\n * No new `UserTaskStatus` value is introduced. The audit lives on the columns\n * Phase 4a added (`reassigned_by` / `reassigned_at` / `reassign_reason`) plus a\n * `WorkflowEvent`, and a reassigned task is exactly as pending or in-progress as\n * it was \u2014 the same choice `releaseUserTask` made, and the one Phase 3a's\n * failure queue made when it reused `PAUSED` plus a marker.\n *\n * An in-flight CLAIM is always released. Handing a row to somebody else while a\n * colleague still holds it would leave `claimedBy ?? assignedTo` naming the old\n * claimant, so the predicate would keep treating the task as theirs and the new\n * assignee could not act on what they were just given.\n *\n * @throws UserTaskError when the task is absent from the caller's scope or is\n * in a terminal status\n */\nexport async function reassignUserTask(\n em: EntityManager,\n options: ReassignUserTaskOptions,\n): Promise<UserTask> {\n const { taskId, userId, target, reason, scope } = options\n\n const task = await em.findOne(UserTask, {\n id: taskId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n\n if (!task) {\n throw new UserTaskError('Task not found', 'TASK_NOT_FOUND', { taskId })\n }\n\n if (!REASSIGNABLE_STATUSES.includes(task.status)) {\n throw new UserTaskError(\n 'Task is no longer open and cannot be reassigned',\n 'TASK_NOT_REASSIGNABLE',\n { taskId, status: task.status },\n )\n }\n\n const assignedTo = target.assignedTo ?? null\n const assignedToRoles =\n target.assignedToRoles && target.assignedToRoles.length > 0 ? [...target.assignedToRoles] : null\n\n const previous = {\n assignedTo: task.assignedTo ?? null,\n assignedToRoles: task.assignedToRoles ?? null,\n claimedBy: task.claimedBy ?? null,\n }\n\n const now = new Date()\n\n task.assignedTo = assignedTo\n // Reassignment is a backoffice administration act and its target is resolved\n // from the tenant's user/role vocabulary, so the row lands in the backoffice\n // namespace. Portal reassignment would need a portal principal picker and is\n // not part of this surface.\n task.assigneeKind = 'user'\n task.assignedToRoles = assignedToRoles\n task.claimedBy = null\n task.claimedAt = null\n // A queued task must be PENDING to be claimable, and a task whose claim was\n // just released is exactly as pending as it was before anyone claimed it.\n if (task.status === 'IN_PROGRESS') task.status = 'PENDING'\n task.reassignedBy = userId\n task.reassignedAt = now\n task.reassignReason = reason ?? null\n task.updatedAt = now\n\n await em.flush()\n\n const instance = await em.findOne(WorkflowInstance, task.workflowInstanceId)\n if (instance) {\n await logWorkflowEvent(em, {\n workflowInstanceId: instance.id,\n stepInstanceId: task.stepInstanceId,\n branchInstanceId: task.branchInstanceId ?? null,\n eventType: 'USER_TASK_REASSIGNED',\n eventData: {\n taskId: task.id,\n taskName: task.taskName,\n reassignedBy: userId,\n reason: reason ?? null,\n from: previous,\n to: { assignedTo, assignedToRoles },\n },\n userId,\n tenantId: instance.tenantId,\n organizationId: instance.organizationId,\n })\n }\n\n return task\n}\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\n/**\n * Log workflow event to event sourcing table\n */\nasync function logWorkflowEvent(\n em: EntityManager,\n event: {\n workflowInstanceId: string\n stepInstanceId: string | null\n branchInstanceId?: string | null\n eventType: string\n eventData: any\n userId?: string\n tenantId: string\n organizationId: string\n }\n): Promise<WorkflowEvent> {\n const workflowEvent = em.create(WorkflowEvent, {\n ...event,\n occurredAt: new Date(),\n })\n\n await em.persist(workflowEvent).flush()\n return workflowEvent\n}\n\n/**\n * Validate form data against the task's authored schema.\n *\n * Required-field presence in BOTH accepted `formSchema` shapes: the JSON-Schema\n * form and the `{ fields: [...] }` form the Studio writes, whose per-field\n * `required: true` flag this used to ignore entirely \u2014 so a task authored in\n * the visual editor validated nothing however many fields its author marked\n * mandatory. Both shapes now resolve through `lib/task-form-schema.ts`, the\n * same module the renderer walks, so they cannot drift apart again.\n *\n * Presence-only, deliberately: type checking would need the full JSON-Schema\n * vocabulary, and what matters is that a required field cannot be skipped.\n *\n * @param formData - User-provided form data\n * @param formSchema - Authored schema, in either accepted shape\n * @throws Error if validation fails\n */\nexport function validateFormData(\n formData: Record<string, any>,\n formSchema: unknown\n): void {\n for (const field of readRequiredFormFields(formSchema)) {\n if (!(field in formData) || formData[field] === null || formData[field] === undefined) {\n throw new Error(`Required field missing: ${field}`)\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Workflows Module - User Task Handler Service\n *\n * Handles user task lifecycle operations:\n * - Completing user tasks\n * - Claiming tasks from role queues\n * - Reassigning tasks\n * - Escalating overdue tasks\n *\n * Functional API (no classes) following Open Mercato conventions.\n */\n\nimport { EntityManager } from '@mikro-orm/core'\nimport type { AwilixContainer } from 'awilix'\nimport {\n UserTask,\n WorkflowInstance,\n WorkflowEvent,\n StepInstance,\n} from '../data/entities'\nimport { executeWorkflow } from './workflow-executor'\nimport { findDefinitionForInstance } from './find-definition'\nimport {\n findTaskDecision,\n findTaskDecisionTransition,\n withRecordedDecision,\n} from './task-decisions'\nimport { loadTaskDecisionContext } from './task-decision-context'\nimport { readRequiredFormFields } from './task-form-schema'\nimport { isRunClosedToUserTasks } from './instance-status'\nimport * as stepHandler from './step-handler'\nimport * as transitionHandler from './transition-handler'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('workflows')\n\n// ============================================================================\n// Types and Interfaces\n// ============================================================================\n\n/**\n * Tenant/organization the caller is acting within. Every task lookup filters on\n * it, so a task id from another tenant resolves to \"not found\" instead of being\n * mutated.\n */\nexport interface UserTaskScope {\n tenantId: string\n organizationId: string\n}\n\n/**\n * Shape of the `taskHandler` DI registration (`di.ts`).\n *\n * The functions below stay exported \u2014 third-party modules may import them and\n * `BACKWARD_COMPATIBILITY.md` \u00A73 freezes import paths \u2014 but callers inside the\n * platform MUST resolve this service instead, per the module's first rule.\n */\nexport interface TaskHandlerService {\n completeUserTask: typeof completeUserTask\n claimUserTask: typeof claimUserTask\n releaseUserTask: typeof releaseUserTask\n reassignUserTask: typeof reassignUserTask\n}\n\nexport interface CompleteUserTaskOptions {\n taskId: string\n formData: Record<string, any>\n userId: string\n comments?: string\n /**\n * The decision button the assignee pressed, when the step authored any.\n *\n * It does two things: it SELECTS the outgoing route (the decision's\n * `transitionId` wins over the first-valid-transition default), and it is\n * RECORDED as part of the completion payload. Omitted, completion behaves\n * exactly as it always did.\n */\n decisionId?: string\n scope: UserTaskScope\n}\n\nexport class UserTaskError extends Error {\n constructor(\n message: string,\n public code: string,\n public details?: any\n ) {\n super(message)\n this.name = 'UserTaskError'\n }\n}\n\n// ============================================================================\n// User Task Completion\n// ============================================================================\n\n/**\n * Complete a user task and resume workflow execution\n *\n * This function:\n * 1. Validates the task exists and can be completed\n * 2. Updates the task with form data and completion info\n * 3. Merges form data into workflow context\n * 4. Logs completion event\n * 5. Resumes workflow execution\n *\n * @param em - Entity manager\n * @param container - DI container for workflow execution\n * @param options - Task completion options\n * @throws UserTaskError if task not found or validation fails\n */\nexport async function completeUserTask(\n em: EntityManager,\n container: AwilixContainer,\n options: CompleteUserTaskOptions\n): Promise<void> {\n const { taskId, formData, userId, comments, decisionId, scope } = options\n\n // Fetch task\n const task = await em.findOne(UserTask, {\n id: taskId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n status: { $in: ['PENDING', 'IN_PROGRESS'] },\n })\n\n if (!task) {\n throw new UserTaskError(\n 'Task not found or already completed',\n 'TASK_NOT_FOUND',\n { taskId }\n )\n }\n\n // A task addressed to one person is that person's to finish. Tasks that name\n // nobody \u2014 agent dispositions, role queues nobody has claimed \u2014 stay open to\n // anyone the route's feature gate already admitted, so this narrows the\n // over-permissive case without stranding the unassigned ones.\n const claimant = task.claimedBy ?? task.assignedTo\n if (claimant && claimant !== userId) {\n throw new UserTaskError(\n 'Task is assigned to another user',\n 'TASK_ASSIGNED_TO_ANOTHER_USER',\n { taskId, assignedTo: claimant }\n )\n }\n\n // Validate form data against schema (simple validation for MVP)\n // In Phase 7, we'll add comprehensive JSON Schema validation\n if (task.formSchema) {\n try {\n validateFormData(formData, task.formSchema)\n } catch (error) {\n throw new UserTaskError(\n error instanceof Error ? error.message : 'Form validation failed',\n 'FORM_VALIDATION_FAILED',\n { taskId, formSchema: task.formSchema, formData }\n )\n }\n }\n\n // Resolve the pressed decision BEFORE anything is mutated: an unknown\n // decision id must leave the task untouched rather than complete it down the\n // default route. The list is re-resolved from the instance's pinned\n // definition, so it is exactly the list the assignee was shown.\n const chosenDecision = decisionId\n ? findTaskDecision((await loadTaskDecisionContext(em, task)).decisions, decisionId)\n : null\n\n if (decisionId && !chosenDecision) {\n throw new UserTaskError(\n 'Unknown decision for this task',\n 'UNKNOWN_DECISION',\n { taskId, decisionId }\n )\n }\n\n const instance = await em.findOne(WorkflowInstance, task.workflowInstanceId)\n if (!instance) {\n throw new UserTaskError(\n 'Workflow instance not found',\n 'INSTANCE_NOT_FOUND',\n { workflowInstanceId: task.workflowInstanceId }\n )\n }\n\n if (isRunClosedToUserTasks(instance.status)) {\n throw new UserTaskError(\n 'Workflow is no longer active',\n 'WORKFLOW_NOT_ACTIVE',\n { taskId, workflowInstanceId: instance.id, status: instance.status }\n )\n }\n\n // The chosen decision rides along inside the completion payload the task\n // already persists \u2014 no new column, and downstream route conditions can read\n // it once the payload is merged into the run context.\n const completionData = withRecordedDecision(formData, chosenDecision?.id)\n\n // Update task\n const now = new Date()\n task.status = 'COMPLETED'\n task.formData = completionData\n task.completedBy = userId\n task.completedAt = now\n task.comments = comments || null\n task.updatedAt = now\n\n await em.flush()\n\n // Branch-scoped completion: when the task belongs to a parallel branch,\n // merge form data into the branch namespace and resume just that branch.\n //\n // The decision is RECORDED here exactly as it is on the main path, but it does\n // not select the route: a branch advances through `resumeBranch` with its own\n // token, and overriding a branch's outgoing route is a parallel-execution\n // change rather than a task-surface one.\n if (task.branchInstanceId) {\n await logWorkflowEvent(em, {\n workflowInstanceId: instance.id,\n stepInstanceId: task.stepInstanceId,\n branchInstanceId: task.branchInstanceId,\n eventType: 'USER_TASK_COMPLETED',\n eventData: {\n taskId: task.id,\n taskName: task.taskName,\n completedBy: userId,\n formData: completionData,\n ...(chosenDecision ? { decisionId: chosenDecision.id } : {}),\n },\n userId,\n tenantId: instance.tenantId,\n organizationId: instance.organizationId,\n })\n\n const { resumeBranch } = await import('./parallel-handler')\n const resumed = await resumeBranch(em, {\n instanceId: instance.id,\n branchInstanceId: task.branchInstanceId,\n tenantId: instance.tenantId,\n organizationId: instance.organizationId,\n contextMerge: completionData,\n exitStepInstanceId: task.stepInstanceId,\n exitOutput: { userTaskId: task.id, formData: completionData },\n })\n\n if (resumed) {\n await executeWorkflow(em, container, instance.id, { userId })\n }\n return\n }\n\n // Merge form data into workflow context\n instance.context = {\n ...instance.context,\n ...completionData,\n }\n instance.updatedAt = now\n\n // Log USER_TASK_COMPLETED event\n await logWorkflowEvent(em, {\n workflowInstanceId: instance.id,\n stepInstanceId: task.stepInstanceId,\n eventType: 'USER_TASK_COMPLETED',\n eventData: {\n taskId: task.id,\n taskName: task.taskName,\n completedBy: userId,\n formData: completionData,\n ...(chosenDecision ? { decisionId: chosenDecision.id } : {}),\n },\n userId,\n tenantId: instance.tenantId,\n organizationId: instance.organizationId,\n })\n\n // Mark the step instance as completed\n const stepInstance = await em.findOne(StepInstance, {\n id: task.stepInstanceId,\n status: 'ACTIVE',\n })\n\n if (stepInstance) {\n await stepHandler.exitStep(em, stepInstance, { userTaskId: task.id, formData: completionData })\n }\n\n // Find the next automatic transition from the current step\n const currentStepId = instance.currentStepId\n\n // Load workflow definition to find transitions\n const definition = await findDefinitionForInstance(em, instance)\n\n if (!definition) {\n throw new UserTaskError(\n 'Workflow definition not found',\n 'DEFINITION_NOT_FOUND',\n { definitionId: instance.definitionId }\n )\n }\n\n const transitionContext = {\n workflowContext: instance.context,\n userId,\n }\n\n // A decision names the route to take. It is bound to the transition's durable\n // id, so it survives every route reorder, and it is looked up among the routes\n // LEAVING this step only \u2014 a button can never send the run down a route that\n // does not start where the run currently is. A decision whose route no longer\n // exists there falls through to the default evaluation rather than stranding\n // the run at a step it already completed.\n const decisionTransition = chosenDecision\n ? findTaskDecisionTransition(\n definition.definition.transitions || [],\n chosenDecision.transitionId,\n currentStepId\n )\n : null\n\n if (chosenDecision && !decisionTransition) {\n logger.warn('Task decision route not found on the current step; falling back to auto routing', {\n component: 'task-handler',\n taskId: task.id,\n decisionId: chosenDecision.id,\n transitionId: chosenDecision.transitionId,\n currentStepId,\n })\n }\n\n let selectedTransition = decisionTransition ?? null\n let targetStepId = selectedTransition?.toStepId ?? null\n\n if (!targetStepId) {\n // Find automatic transitions from current step\n const autoTransitions = (definition.definition.transitions || []).filter(\n (t: any) => t.fromStepId === currentStepId && t.trigger === 'auto'\n )\n\n if (autoTransitions.length === 0) {\n // No automatic transitions, workflow stays paused at current step\n return\n }\n\n // Find valid transitions using transition handler\n const validTransitions = await transitionHandler.findValidTransitions(\n em,\n instance,\n currentStepId,\n transitionContext\n )\n\n const firstValidTransition = validTransitions.find(t => t.isValid)\n\n if (!firstValidTransition || !firstValidTransition.transition) {\n // Resume workflow execution anyway, maybe conditions will be met later\n instance.status = 'RUNNING'\n await em.flush()\n return\n }\n\n targetStepId = firstValidTransition.transition.toStepId\n selectedTransition = firstValidTransition.transition\n }\n\n // Resume from the paused wait BEFORE executing the transition, exactly as\n // `sendSignal` does. Without it the executor's defense-in-depth PAUSED check\n // stops the run at the first step the completion traverses, so the instance\n // sits there as PAUSED and never reaches END. Guarded on PAUSED so completing\n // a stale task can never resurrect a CANCELLED or terminal run.\n if (instance.status === 'PAUSED') {\n instance.status = 'RUNNING'\n await em.flush()\n }\n\n // Execute the transition to move to next step\n\n const transitionResult = await transitionHandler.executeTransition(\n em,\n container,\n instance,\n currentStepId,\n targetStepId,\n { ...transitionContext, transitionId: selectedTransition?.transitionId },\n )\n\n if (!transitionResult.success) {\n logger.error('Transition failed', { component: 'task-handler', err: transitionResult.error })\n // Don't throw, just leave workflow in current state\n return\n }\n\n // Now continue workflow execution from the new step\n await executeWorkflow(em, container, instance.id, { userId })\n}\n\n/**\n * Whether the caller belongs to at least one of the task's queues.\n *\n * Exact string equality on role NAMES \u2014 no case folding and no trimming, because\n * the names on both sides come from the same tenant role table and normalizing\n * one side would let `Approver` claim an `approver` queue that RBAC treats as a\n * different role.\n */\nfunction hasRoleOverlap(\n taskRoles: readonly string[],\n callerRoleNames: readonly string[],\n): boolean {\n if (callerRoleNames.length === 0) return false\n const held = new Set(callerRoleNames)\n return taskRoles.some((role) => held.has(role))\n}\n\n/**\n * Claim a user task from a role queue\n *\n * Allows a user to claim a task that's assigned to their role(s).\n *\n * The claim itself is a COMPARE-AND-SET, not a read-then-write: the lookup\n * below only decides which error to report, while the row is taken with a\n * conditional `UPDATE \u2026 WHERE status = 'PENDING' AND claimed_by IS NULL`. Two\n * callers racing for the same task \u2014 which is exactly what the work inbox's\n * claim-next affordance produces \u2014 therefore cannot both win; the loser sees\n * zero affected rows and the same `TASK_NOT_FOUND` it would have seen a moment\n * later. Reading the status and flushing the entity afterwards would have let\n * both transactions read `PENDING` and both write.\n *\n * Membership in the queue is a REQUIREMENT, not a hint: a caller who holds none\n * of `assignedToRoles` is refused with the same `TASK_NOT_FOUND` a nonexistent\n * id produces, so a queue the caller does not belong to is indistinguishable\n * from a task that is not there. Holding `workflows.tasks.claim` admits you to\n * the endpoint; it does not admit you to somebody else's queue.\n *\n * `assignedToRoles` and `callerRoleNames` are both role NAMES \u2014 the Studio\n * picker writes names, the engine copies them onto the task, and `auth.roles`\n * carries names. A role RENAME therefore orphans existing assignments; moving\n * the comparison onto immutable role ids is a coordinated data + authored-\n * definition migration and is deliberately not done here.\n *\n * @param em - Entity manager\n * @param taskId - Task ID to claim\n * @param userId - User claiming the task\n * @param scope - Tenant/organization the caller acts within; both the lookup and\n * the conditional update filter on it, so a task belonging to another tenant\n * is never reachable and never writable\n * @param callerRoleNames - Server-derived role names of the caller (`auth.roles`),\n * never client-supplied. Required rather than optional so a caller that cannot\n * supply them fails to compile instead of silently claiming any queue.\n * @throws UserTaskError if task cannot be claimed\n */\nexport async function claimUserTask(\n em: EntityManager,\n taskId: string,\n userId: string,\n scope: UserTaskScope,\n callerRoleNames: readonly string[]\n): Promise<void> {\n const task = await em.findOne(UserTask, {\n id: taskId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n status: 'PENDING',\n })\n\n if (!task) {\n throw new UserTaskError(\n 'Task not found or already claimed',\n 'TASK_NOT_FOUND',\n { taskId }\n )\n }\n\n if (task.assignedTo) {\n throw new UserTaskError(\n 'Task is already assigned to a specific user',\n 'TASK_ALREADY_ASSIGNED',\n { taskId, assignedTo: task.assignedTo }\n )\n }\n\n if (!task.assignedToRoles || task.assignedToRoles.length === 0) {\n throw new UserTaskError(\n 'Task is not assigned to any roles',\n 'TASK_NOT_ROLE_ASSIGNED',\n { taskId }\n )\n }\n\n if (!hasRoleOverlap(task.assignedToRoles, callerRoleNames)) {\n throw new UserTaskError(\n 'Task not found or already claimed',\n 'TASK_NOT_FOUND',\n { taskId }\n )\n }\n\n const now = new Date()\n const claimed = await em.nativeUpdate(\n UserTask,\n {\n id: taskId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n status: 'PENDING',\n claimedBy: null,\n },\n {\n claimedBy: userId,\n claimedAt: now,\n status: 'IN_PROGRESS',\n updatedAt: now,\n },\n )\n\n if (claimed === 0) {\n throw new UserTaskError(\n 'Task not found or already claimed',\n 'TASK_NOT_FOUND',\n { taskId }\n )\n }\n\n // The conditional update above already wrote the row; mirroring it onto the\n // managed entity keeps the caller's identity-mapped copy from reporting the\n // pre-claim state back to the client.\n task.claimedBy = userId\n task.claimedAt = now\n task.status = 'IN_PROGRESS'\n task.updatedAt = now\n\n await em.flush()\n\n // Log event\n const instance = await em.findOne(WorkflowInstance, task.workflowInstanceId)\n if (instance) {\n await logWorkflowEvent(em, {\n workflowInstanceId: instance.id,\n stepInstanceId: task.stepInstanceId,\n eventType: 'USER_TASK_STARTED',\n eventData: {\n taskId: task.id,\n taskName: task.taskName,\n claimedBy: userId,\n },\n userId,\n tenantId: instance.tenantId,\n organizationId: instance.organizationId,\n })\n }\n}\n\n/**\n * Release a claimed task back to its role queue.\n *\n * The counterpart of `claimUserTask`, and deliberately its mirror image: the\n * write is the same compare-and-set, guarded on the caller still being the\n * claimant, so a task reassigned or completed in the meantime is refused rather\n * than silently reopened. It uses the EXISTING statuses \u2014 the row goes back to\n * `PENDING` \u2014 because adding a `UserTaskStatus` value is an Ask-First\n * state-machine change and a released task is exactly as pending as it was\n * before anyone claimed it.\n *\n * @throws UserTaskError if the task is not the caller's to release\n */\nexport async function releaseUserTask(\n em: EntityManager,\n taskId: string,\n userId: string,\n scope: UserTaskScope\n): Promise<void> {\n const task = await em.findOne(UserTask, {\n id: taskId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n status: 'IN_PROGRESS',\n })\n\n if (!task) {\n throw new UserTaskError(\n 'Task not found or not claimed',\n 'TASK_NOT_FOUND',\n { taskId }\n )\n }\n\n if (task.claimedBy !== userId) {\n throw new UserTaskError(\n 'Task is claimed by another user',\n 'TASK_ASSIGNED_TO_ANOTHER_USER',\n { taskId, claimedBy: task.claimedBy }\n )\n }\n\n const now = new Date()\n const released = await em.nativeUpdate(\n UserTask,\n {\n id: taskId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n status: 'IN_PROGRESS',\n claimedBy: userId,\n },\n {\n claimedBy: null,\n claimedAt: null,\n status: 'PENDING',\n updatedAt: now,\n },\n )\n\n if (released === 0) {\n throw new UserTaskError(\n 'Task not found or not claimed',\n 'TASK_NOT_FOUND',\n { taskId }\n )\n }\n\n task.claimedBy = null\n task.claimedAt = null\n task.status = 'PENDING'\n task.updatedAt = now\n\n await em.flush()\n\n const instance = await em.findOne(WorkflowInstance, task.workflowInstanceId)\n if (instance) {\n await logWorkflowEvent(em, {\n workflowInstanceId: instance.id,\n stepInstanceId: task.stepInstanceId,\n eventType: 'USER_TASK_RELEASED',\n eventData: {\n taskId: task.id,\n taskName: task.taskName,\n releasedBy: userId,\n },\n userId,\n tenantId: instance.tenantId,\n organizationId: instance.organizationId,\n })\n }\n}\n\n// ============================================================================\n// User Task Reassignment\n// ============================================================================\n\n/**\n * The new owner of a task, as the reassign route resolved it.\n *\n * At least one of the two must be non-empty, and the route enforces it: the\n * shape \u00A76.4 makes uncompletable is precisely \"no assignee AND no role queue\",\n * so a reassignment that produced it would be the one write that can create the\n * problem reassignment exists to fix.\n */\nexport interface ReassignUserTaskTarget {\n assignedTo?: string | null\n assignedToRoles?: readonly string[] | null\n}\n\nexport interface ReassignUserTaskOptions {\n taskId: string\n /** Who performed the reassignment \u2014 recorded on `reassigned_by`. */\n userId: string\n target: ReassignUserTaskTarget\n reason?: string | null\n scope: UserTaskScope\n}\n\n/**\n * Statuses a task can be reassigned in.\n *\n * The same pair `completeUserTask` accepts: moving a COMPLETED or CANCELLED row\n * to somebody else records an audit trail for work that can never be done, and\n * the assignee would be handed a task whose Complete button always fails.\n */\nconst REASSIGNABLE_STATUSES: readonly string[] = ['PENDING', 'IN_PROGRESS']\n\n/**\n * Move a task to a different assignee or role queue, with an audit trail.\n *\n * This is the ACT half of the \u00A76.4 model's central asymmetry: administration\n * widens seeing, never acting, so an administrator who can see a colleague's\n * task \u2014 or a task that belongs to nobody at all \u2014 takes it first and the taking\n * is permanently recorded. It is also the documented remedy for the one shape\n * the model leaves uncompletable (no assignee, no claim, no role queue), which\n * is why it deliberately accepts a task in exactly that state.\n *\n * No new `UserTaskStatus` value is introduced. The audit lives on the columns\n * Phase 4a added (`reassigned_by` / `reassigned_at` / `reassign_reason`) plus a\n * `WorkflowEvent`, and a reassigned task is exactly as pending or in-progress as\n * it was \u2014 the same choice `releaseUserTask` made, and the one Phase 3a's\n * failure queue made when it reused `PAUSED` plus a marker.\n *\n * An in-flight CLAIM is always released. Handing a row to somebody else while a\n * colleague still holds it would leave `claimedBy ?? assignedTo` naming the old\n * claimant, so the predicate would keep treating the task as theirs and the new\n * assignee could not act on what they were just given.\n *\n * @throws UserTaskError when the task is absent from the caller's scope or is\n * in a terminal status\n */\nexport async function reassignUserTask(\n em: EntityManager,\n options: ReassignUserTaskOptions,\n): Promise<UserTask> {\n const { taskId, userId, target, reason, scope } = options\n\n const task = await em.findOne(UserTask, {\n id: taskId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n\n if (!task) {\n throw new UserTaskError('Task not found', 'TASK_NOT_FOUND', { taskId })\n }\n\n if (!REASSIGNABLE_STATUSES.includes(task.status)) {\n throw new UserTaskError(\n 'Task is no longer open and cannot be reassigned',\n 'TASK_NOT_REASSIGNABLE',\n { taskId, status: task.status },\n )\n }\n\n const assignedTo = target.assignedTo ?? null\n const assignedToRoles =\n target.assignedToRoles && target.assignedToRoles.length > 0 ? [...target.assignedToRoles] : null\n\n const previous = {\n assignedTo: task.assignedTo ?? null,\n assignedToRoles: task.assignedToRoles ?? null,\n claimedBy: task.claimedBy ?? null,\n }\n\n const now = new Date()\n\n task.assignedTo = assignedTo\n // Reassignment is a backoffice administration act and its target is resolved\n // from the tenant's user/role vocabulary, so the row lands in the backoffice\n // namespace. Portal reassignment would need a portal principal picker and is\n // not part of this surface.\n task.assigneeKind = 'user'\n task.assignedToRoles = assignedToRoles\n task.claimedBy = null\n task.claimedAt = null\n // A queued task must be PENDING to be claimable, and a task whose claim was\n // just released is exactly as pending as it was before anyone claimed it.\n if (task.status === 'IN_PROGRESS') task.status = 'PENDING'\n task.reassignedBy = userId\n task.reassignedAt = now\n task.reassignReason = reason ?? null\n task.updatedAt = now\n\n await em.flush()\n\n const instance = await em.findOne(WorkflowInstance, task.workflowInstanceId)\n if (instance) {\n await logWorkflowEvent(em, {\n workflowInstanceId: instance.id,\n stepInstanceId: task.stepInstanceId,\n branchInstanceId: task.branchInstanceId ?? null,\n eventType: 'USER_TASK_REASSIGNED',\n eventData: {\n taskId: task.id,\n taskName: task.taskName,\n reassignedBy: userId,\n reason: reason ?? null,\n from: previous,\n to: { assignedTo, assignedToRoles },\n },\n userId,\n tenantId: instance.tenantId,\n organizationId: instance.organizationId,\n })\n }\n\n return task\n}\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\n/**\n * Log workflow event to event sourcing table\n */\nasync function logWorkflowEvent(\n em: EntityManager,\n event: {\n workflowInstanceId: string\n stepInstanceId: string | null\n branchInstanceId?: string | null\n eventType: string\n eventData: any\n userId?: string\n tenantId: string\n organizationId: string\n }\n): Promise<WorkflowEvent> {\n const workflowEvent = em.create(WorkflowEvent, {\n ...event,\n occurredAt: new Date(),\n })\n\n await em.persist(workflowEvent).flush()\n return workflowEvent\n}\n\n/**\n * Validate form data against the task's authored schema.\n *\n * Required-field presence in BOTH accepted `formSchema` shapes: the JSON-Schema\n * form and the `{ fields: [...] }` form the Studio writes, whose per-field\n * `required: true` flag this used to ignore entirely \u2014 so a task authored in\n * the visual editor validated nothing however many fields its author marked\n * mandatory. Both shapes now resolve through `lib/task-form-schema.ts`, the\n * same module the renderer walks, so they cannot drift apart again.\n *\n * Presence-only, deliberately: type checking would need the full JSON-Schema\n * vocabulary, and what matters is that a required field cannot be skipped.\n *\n * @param formData - User-provided form data\n * @param formSchema - Authored schema, in either accepted shape\n * @throws Error if validation fails\n */\nexport function validateFormData(\n formData: Record<string, any>,\n formSchema: unknown\n): void {\n for (const field of readRequiredFormFields(formSchema)) {\n if (!(field in formData) || formData[field] === null || formData[field] === undefined) {\n throw new Error(`Required field missing: ${field}`)\n }\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -7,6 +7,7 @@ import {
7
7
  INVOKE_AGENT_ACTIVITY_TYPE
8
8
  } from "./agent-review.js";
9
9
  import { findDefinitionForInstance } from "./find-definition.js";
10
+ import { isRunClosedToUserTasks } from "./instance-status.js";
10
11
  import { emitWorkflowsEvent } from "../events.js";
11
12
  import { createLogger } from "@open-mercato/shared/lib/logger";
12
13
  const logger = createLogger("workflows");
@@ -187,6 +188,9 @@ async function applyBreachHandling(em, container, task, options, now) {
187
188
  if (resolution.kind === "notify") return { outcome: "notified" };
188
189
  if (resolution.kind === "attention") {
189
190
  if (!instance) return { outcome: "none" };
191
+ if (isRunClosedToUserTasks(instance.status)) {
192
+ return { outcome: "attention_skipped_closed_run", detail: { stepId, status: instance.status } };
193
+ }
190
194
  await markInstanceForAttention(em, instance, stepId, task, now);
191
195
  return { outcome: "attention", detail: { stepId } };
192
196
  }
@@ -215,6 +219,12 @@ async function applyBreachHandling(em, container, task, options, now) {
215
219
  if (options.branchInstanceId) {
216
220
  return { outcome: "route_skipped_branch", detail: { transitionId: resolution.transition.transitionId } };
217
221
  }
222
+ if (isRunClosedToUserTasks(instance?.status)) {
223
+ return {
224
+ outcome: "route_skipped_closed_run",
225
+ detail: { transitionId: resolution.transition.transitionId, status: instance?.status }
226
+ };
227
+ }
218
228
  task.status = "ESCALATED";
219
229
  task.updatedAt = now;
220
230
  await em.flush();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/workflows/lib/task-sla.ts"],
4
- "sourcesContent": ["/**\n * Task SLA scheduler \u2014 reminders and deadline breach for USER_TASK steps.\n *\n * `dueDate` has been display-only until now: nothing ever read it back, and the\n * authored `escalationRules` were dead config. This module makes the deadline\n * real, and it does so on the queue rather than with a poller \u2014 reusing the\n * Phase-3a absolute-deadline backstop the WAIT_FOR_CONDITION step already\n * relies on:\n *\n * - every job is enqueued ONCE, at task creation, with a delay;\n * - `deadlineAt` is ABSOLUTE and rides on the payload, so a queue running\n * behind cannot silently extend the deadline the author configured;\n * - the handler is IDEMPOTENT \u2014 a completed, cancelled or already-breached\n * task makes the job a no-op, and the breach is claimed with a conditional\n * UPDATE so at-least-once delivery still fires it exactly once.\n *\n * `computeTaskSlaSchedule` is PURE (no ORM, DI or registry imports) so the\n * scheduling arithmetic is unit-testable without a workflow.\n */\n\nimport type { EntityManager } from '@mikro-orm/core'\nimport type { AwilixContainer } from 'awilix'\nimport { StepInstance, UserTask, WorkflowInstance } from '../data/entities'\nimport type { TaskOnBreach, TaskReminder } from '../data/validators'\nimport { parseDuration } from './duration'\nimport { resolveTaskBreachHandling, type TaskBreachHandling } from './breach-routing'\nimport {\n findAgentReviewConfig,\n resolveAgentReviewBreachHandling,\n INVOKE_AGENT_ACTIVITY_TYPE,\n type AgentReviewBreachHandling,\n type AgentReviewStepLike,\n} from './agent-review'\nimport { findDefinitionForInstance } from './find-definition'\nimport { emitWorkflowsEvent } from '../events'\nimport type * as stepHandlerModule from './step-handler'\nimport type * as transitionHandlerModule from './transition-handler'\nimport type * as workflowExecutorModule from './workflow-executor'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('workflows')\n\nexport type TaskSlaPhase = 'reminder' | 'breach'\n\nexport interface TaskSlaScheduleEntry {\n phase: TaskSlaPhase\n /** Absolute time this job should run. */\n fireAt: string\n /** Delay from `now`; never negative. */\n delayMs: number\n}\n\nexport interface TaskSlaSchedule {\n entries: TaskSlaScheduleEntry[]\n /** Reminder offsets that could not be parsed, so the caller can log them. */\n invalidOffsets: string[]\n}\n\n/** Statuses a task is still workable in \u2014 anything else makes an SLA job moot. */\nconst OPEN_TASK_STATUSES = ['PENDING', 'IN_PROGRESS'] as const\n\n/**\n * Resolve the jobs to enqueue for one task.\n *\n * A task with no deadline schedules NOTHING \u2014 reminders without a deadline have\n * nothing to count back from, so they are silently irrelevant rather than an\n * error. A reminder whose moment has already passed is dropped: firing it at\n * creation time would be noise, not a nudge. A reminder offset at or beyond the\n * whole deadline is likewise dropped \u2014 the breach job already speaks for that\n * moment.\n */\nexport function computeTaskSlaSchedule(options: {\n dueDate: Date | null | undefined\n reminders?: TaskReminder[] | null\n now?: Date\n}): TaskSlaSchedule {\n const { dueDate } = options\n if (!dueDate || Number.isNaN(dueDate.getTime())) return { entries: [], invalidOffsets: [] }\n\n const nowMs = (options.now ?? new Date()).getTime()\n const deadlineMs = dueDate.getTime()\n const entries: TaskSlaScheduleEntry[] = []\n const invalidOffsets: string[] = []\n\n for (const reminder of options.reminders ?? []) {\n const offset = reminder?.offset\n if (typeof offset !== 'string' || !offset.length) continue\n\n let offsetMs: number\n try {\n offsetMs = parseDuration(offset)\n } catch {\n invalidOffsets.push(offset)\n continue\n }\n\n if (offsetMs <= 0) continue\n const remindAtMs = deadlineMs - offsetMs\n if (remindAtMs <= nowMs || remindAtMs >= deadlineMs) continue\n\n entries.push({\n phase: 'reminder',\n fireAt: new Date(remindAtMs).toISOString(),\n delayMs: remindAtMs - nowMs,\n })\n }\n\n entries.push({\n phase: 'breach',\n fireAt: dueDate.toISOString(),\n delayMs: Math.max(0, deadlineMs - nowMs),\n })\n\n entries.sort((left, right) => left.delayMs - right.delayMs)\n\n return { entries, invalidOffsets }\n}\n\nexport interface ScheduleUserTaskSlaOptions {\n workflowInstanceId: string\n stepInstanceId: string\n branchInstanceId?: string | null\n userTaskId: string\n dueDate: Date | null | undefined\n reminders?: TaskReminder[] | null\n tenantId: string\n organizationId: string\n userId?: string\n now?: Date\n}\n\n/**\n * Enqueue every SLA job a freshly created task needs.\n *\n * Best-effort by contract: a queue failure must never break the workflow step\n * that created the task, exactly as the assignment event's emission does not.\n */\nexport async function scheduleUserTaskSla(\n options: ScheduleUserTaskSlaOptions\n): Promise<string[]> {\n const schedule = computeTaskSlaSchedule({\n dueDate: options.dueDate,\n reminders: options.reminders,\n now: options.now,\n })\n\n if (schedule.invalidOffsets.length) {\n logger.warn('Task reminder offsets skipped because they are not valid durations', {\n component: 'task-sla',\n taskId: options.userTaskId,\n invalidOffsets: schedule.invalidOffsets,\n })\n }\n\n if (schedule.entries.length === 0) return []\n\n const deadlineAt = options.dueDate ? options.dueDate.toISOString() : null\n if (!deadlineAt) return []\n\n const jobIds: string[] = []\n try {\n const { enqueueTaskSlaJob } = await import('./activity-executor')\n for (const entry of schedule.entries) {\n const jobId = await enqueueTaskSlaJob({\n workflowInstanceId: options.workflowInstanceId,\n stepInstanceId: options.stepInstanceId,\n branchInstanceId: options.branchInstanceId ?? null,\n userTaskId: options.userTaskId,\n phase: entry.phase,\n deadlineAt,\n fireAt: entry.fireAt,\n delayMs: entry.delayMs,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n userId: options.userId,\n })\n jobIds.push(jobId)\n }\n } catch (error) {\n logger.error('Failed to schedule task SLA jobs', {\n component: 'task-sla',\n taskId: options.userTaskId,\n err: error,\n })\n }\n\n return jobIds\n}\n\nexport type TaskSlaOutcome = 'noop' | 'reminded' | 'breached'\n\nexport interface RunTaskSlaJobOptions {\n userTaskId: string\n stepInstanceId: string\n workflowInstanceId: string\n branchInstanceId?: string | null\n phase: TaskSlaPhase\n deadlineAt: string\n tenantId: string\n organizationId: string\n userId?: string\n}\n\n/**\n * Run one SLA job.\n *\n * Every early return is a deliberate no-op rather than an error: the queue\n * retries, and a task that was completed in the meantime is the ordinary case,\n * not a failure.\n */\nexport async function runTaskSlaJob(\n em: EntityManager,\n container: AwilixContainer,\n options: RunTaskSlaJobOptions\n): Promise<TaskSlaOutcome> {\n const { tenantId, organizationId } = options\n\n const task = await em.findOne(UserTask, {\n id: options.userTaskId,\n tenantId,\n organizationId,\n })\n\n if (!task) return 'noop'\n if (!OPEN_TASK_STATUSES.includes(task.status as (typeof OPEN_TASK_STATUSES)[number])) return 'noop'\n\n const deadlineMs = Date.parse(options.deadlineAt)\n if (Number.isNaN(deadlineMs)) return 'noop'\n\n if (options.phase === 'reminder') {\n // A reminder that arrives after the deadline has nothing left to warn\n // about, and one for an already-breached task is strictly noise.\n if (task.escalatedAt) return 'noop'\n if (Date.now() >= deadlineMs) return 'noop'\n\n await logTaskSlaEvent(em, container, options, 'USER_TASK_REMINDER_DUE', {\n taskId: task.id,\n taskName: task.taskName,\n dueDate: options.deadlineAt,\n })\n\n await emitTaskSlaEvent(em, 'workflows.task.reminder_due', task, options)\n return 'reminded'\n }\n\n // Claim the breach with a conditional UPDATE rather than a read-then-write:\n // the queue delivers at least once, and two deliveries reading `escalated_at\n // IS NULL` would both fire. The loser updates zero rows and no-ops.\n const now = new Date()\n const claimed = await em.nativeUpdate(\n UserTask,\n {\n id: task.id,\n tenantId,\n organizationId,\n status: { $in: [...OPEN_TASK_STATUSES] },\n escalatedAt: null,\n },\n { escalatedAt: now, updatedAt: now }\n )\n\n if (claimed === 0) return 'noop'\n\n task.escalatedAt = now\n task.updatedAt = now\n\n // WHICH path the breach took is part of the audit trail, not an implementation\n // detail: \"the deadline passed\" and \"the deadline passed and the run was\n // routed away from this task\" are different facts about the same instant.\n const handling = await applyBreachHandling(em, container, task, options, now)\n\n await logTaskSlaEvent(em, container, options, 'USER_TASK_DEADLINE_BREACHED', {\n taskId: task.id,\n taskName: task.taskName,\n dueDate: options.deadlineAt,\n breachedAt: now.toISOString(),\n onBreach: handling.outcome,\n ...(handling.detail ?? {}),\n })\n\n await emitTaskSlaEvent(em, 'workflows.task.deadline_breached', task, options)\n\n // A reassignment only means anything once the new owner is told about it, and\n // the assignment event is the one path that already reaches both an individual\n // and a role queue.\n if (handling.outcome === 'reassigned') {\n await emitTaskSlaEvent(em, 'workflows.task.assigned', task, options)\n }\n\n if (handling.resumeInstance) {\n await resumeAfterBreachRoute(em, container, task, options, handling.resumeInstance)\n }\n\n return 'breached'\n}\n\ntype BreachOutcome =\n | 'none'\n | 'notified'\n | 'reassigned'\n | 'routed'\n | 'route_skipped_branch'\n | 'attention'\n\ninterface AppliedBreachHandling {\n outcome: BreachOutcome\n detail?: Record<string, unknown>\n resumeInstance?: { stepId: string; toStepId: string; transitionId?: string }\n}\n\ntype BreachResolution = TaskBreachHandling | AgentReviewBreachHandling\n\n/**\n * A step whose breached task is an agent DISPOSITION task rather than an\n * authored USER_TASK. Recognised structurally from the compiled step (an\n * AUTOMATED step carrying an INVOKE_AGENT activity), which is the same shape\n * `graph-utils` round-trips the node from.\n */\nfunction isInvokeAgentStepDef(step: AgentReviewStepLike | undefined): boolean {\n return (step?.activities ?? []).some(\n (activity) => activity?.activityType === INVOKE_AGENT_ACTIVITY_TYPE\n )\n}\n\n/**\n * Apply the resolver's answer to the task row. Routing itself is deferred to\n * `resumeAfterBreachRoute` so the workflow only advances after the breach has\n * been recorded and announced.\n *\n * Which resolver answers depends on WHAT the task is. An authored USER_TASK\n * keeps the full \u00A76.1 vocabulary, including \"follow a route\". An agent\n * disposition task (\u00A77.5) gets the escalate-only one: notify, reassign, or mark\n * the run for attention. It can reach neither a verdict nor a route, because\n * both would resolve a pending proposal without a human \u2014 routing the run past\n * a proposal nobody answered silently drops the mutation the agent proposed,\n * which is a rejection in everything but name. A hand-authored `kind:'slaBreach'`\n * transition on an agent step therefore cannot fire either: the branch below is\n * taken from the STEP's shape, not from the config the author supplied.\n */\nasync function applyBreachHandling(\n em: EntityManager,\n container: AwilixContainer,\n task: UserTask,\n options: RunTaskSlaJobOptions,\n now: Date\n): Promise<AppliedBreachHandling> {\n let resolution: BreachResolution = { kind: 'none' }\n let stepId: string | null = null\n let instance: WorkflowInstance | null = null\n\n try {\n const stepInstance = await em.findOne(StepInstance, {\n id: options.stepInstanceId,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n })\n stepId = stepInstance?.stepId ?? null\n\n instance = await em.findOne(WorkflowInstance, {\n id: options.workflowInstanceId,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n })\n\n if (stepId && instance) {\n const definition = await findDefinitionForInstance(em, instance)\n const stepDef = (definition?.definition?.steps ?? []).find(\n (step: { stepId?: string }) => step.stepId === stepId\n ) as\n | ({ userTaskConfig?: { onBreach?: TaskOnBreach | null } } & AgentReviewStepLike)\n | undefined\n\n resolution = isInvokeAgentStepDef(stepDef)\n ? resolveAgentReviewBreachHandling(findAgentReviewConfig(stepDef)?.onBreach ?? null)\n : resolveTaskBreachHandling(\n definition?.definition ?? null,\n stepId,\n stepDef?.userTaskConfig?.onBreach ?? null\n )\n }\n } catch (error) {\n // A definition the engine can no longer read must not strand the breach:\n // the task is already marked escalated and the event still fires.\n logger.error('Failed to resolve breach handling; falling back to notify-only', {\n component: 'task-sla',\n taskId: task.id,\n err: error,\n })\n return { outcome: 'none' }\n }\n\n if (resolution.kind === 'notify') return { outcome: 'notified' }\n\n if (resolution.kind === 'attention') {\n if (!instance) return { outcome: 'none' }\n await markInstanceForAttention(em, instance, stepId, task, now)\n return { outcome: 'attention', detail: { stepId } }\n }\n\n if (resolution.kind === 'reassign') {\n task.assignedTo = resolution.assignedTo\n task.assignedToRoles = resolution.assignedToRoles\n task.claimedBy = null\n task.claimedAt = null\n task.status = 'PENDING'\n task.escalatedTo = resolution.assignedTo ?? resolution.assignedToRoles?.[0] ?? null\n task.reassignedAt = now\n task.reassignReason = 'sla_breach'\n task.updatedAt = now\n await em.flush()\n return {\n outcome: 'reassigned',\n detail: {\n reassignedTo: resolution.assignedTo,\n reassignedToRoles: resolution.assignedToRoles,\n },\n }\n }\n\n if (resolution.kind === 'route') {\n const toStepId = resolution.transition.toStepId\n if (!stepId || !toStepId) return { outcome: 'none' }\n\n // A branch-scoped task does NOT follow its breach route, for the same\n // reason a decision button does not select a route inside a branch\n // (`completeUserTask`): a branch advances through `resumeBranch` with its\n // own token, and overriding that is a parallel-execution change rather than\n // a task-surface one.\n if (options.branchInstanceId) {\n return { outcome: 'route_skipped_branch', detail: { transitionId: resolution.transition.transitionId } }\n }\n\n // The task is superseded by the route, not abandoned: `ESCALATED` is an\n // existing `UserTaskStatus`, so no state machine changes.\n task.status = 'ESCALATED'\n task.updatedAt = now\n await em.flush()\n\n return {\n outcome: 'routed',\n detail: { transitionId: resolution.transition.transitionId, toStepId },\n resumeInstance: { stepId, toStepId, transitionId: resolution.transition.transitionId },\n }\n }\n\n return { outcome: 'none' }\n}\n\n/**\n * Mark a run for triage without touching its state machine.\n *\n * The instance is ALREADY paused \u2014 an agent step parks on the proposal-ready\n * signal \u2014 so this writes only the engine-owned `metadata.attention` marker the\n * failure queue already uses (`GET /api/workflows/instances?attention=true`\n * lists it). Deliberately no status write, no `errorMessage`: the run is not\n * failing, it is waiting on a person who is late, and the proposal is still\n * pending for whoever picks it up.\n */\nasync function markInstanceForAttention(\n em: EntityManager,\n instance: WorkflowInstance,\n stepId: string | null,\n task: UserTask,\n now: Date\n): Promise<void> {\n instance.metadata = {\n ...(instance.metadata || {}),\n attention: {\n reason: 'DISPOSITION_SLA_BREACH',\n ...(stepId ? { stepId } : {}),\n at: now.toISOString(),\n },\n }\n instance.updatedAt = now\n await em.flush()\n\n logger.warn('Agent disposition deadline passed; run marked for attention', {\n component: 'task-sla',\n taskId: task.id,\n workflowInstanceId: instance.id,\n stepId,\n })\n}\n\n/**\n * Follow the breach route: exit the task's step and drive the run onward,\n * mirroring how `fireTimer` resumes a run parked on a WAIT_FOR_TIMER step.\n *\n * Best-effort: a routing failure leaves the run exactly where it was, with the\n * breach already recorded, rather than throwing the queue job into a retry loop\n * that would re-announce a breach that has already fired.\n */\nasync function resumeAfterBreachRoute(\n em: EntityManager,\n container: AwilixContainer,\n task: UserTask,\n options: RunTaskSlaJobOptions,\n route: { stepId: string; toStepId: string; transitionId?: string }\n): Promise<void> {\n try {\n const instance = await em.findOne(WorkflowInstance, {\n id: options.workflowInstanceId,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n })\n if (!instance) return\n\n const stepHandler = container.resolve<typeof stepHandlerModule>('stepHandler')\n const transitionHandler = container.resolve<typeof transitionHandlerModule>('transitionHandler')\n const workflowExecutor = container.resolve<typeof workflowExecutorModule>('workflowExecutor')\n\n const stepInstance = await em.findOne(StepInstance, {\n id: options.stepInstanceId,\n status: 'ACTIVE',\n })\n if (stepInstance) {\n await stepHandler.exitStep(em, stepInstance, {\n userTaskId: task.id,\n slaBreached: true,\n })\n }\n\n const transitionResult = await transitionHandler.executeTransition(\n em,\n container,\n instance,\n route.stepId,\n route.toStepId,\n {\n workflowContext: instance.context,\n userId: options.userId,\n transitionId: route.transitionId,\n },\n )\n\n if (!transitionResult.success) {\n logger.error('SLA-breach transition failed', {\n component: 'task-sla',\n taskId: task.id,\n err: transitionResult.error,\n })\n return\n }\n\n await workflowExecutor.executeWorkflow(em, container, instance.id, { userId: options.userId })\n } catch (error) {\n logger.error('Failed to follow the SLA-breach route', {\n component: 'task-sla',\n taskId: task.id,\n err: error,\n })\n }\n}\n\nasync function logTaskSlaEvent(\n em: EntityManager,\n container: AwilixContainer,\n options: RunTaskSlaJobOptions,\n eventType: string,\n eventData: Record<string, unknown>\n): Promise<void> {\n try {\n const eventLogger = container.resolve<{\n logWorkflowEvent: (\n em: EntityManager,\n event: Record<string, unknown>\n ) => Promise<unknown>\n }>('eventLogger')\n\n await eventLogger.logWorkflowEvent(em, {\n workflowInstanceId: options.workflowInstanceId,\n stepInstanceId: options.stepInstanceId,\n ...(options.branchInstanceId ? { branchInstanceId: options.branchInstanceId } : {}),\n eventType,\n eventData,\n userId: options.userId,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n })\n } catch (error) {\n logger.error('Failed to log task SLA workflow event', {\n component: 'task-sla',\n taskId: options.userTaskId,\n eventType,\n err: error,\n })\n }\n}\n\nasync function emitTaskSlaEvent(\n em: EntityManager,\n eventId: 'workflows.task.reminder_due' | 'workflows.task.deadline_breached' | 'workflows.task.assigned',\n task: UserTask,\n options: RunTaskSlaJobOptions\n): Promise<void> {\n try {\n const instance = await em.findOne(WorkflowInstance, {\n id: options.workflowInstanceId,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n })\n\n await emitWorkflowsEvent(\n eventId,\n {\n taskId: task.id,\n taskName: task.taskName,\n workflowInstanceId: options.workflowInstanceId,\n workflowId: instance?.workflowId ?? null,\n workflowName: instance?.workflowId ?? '',\n assignedUserId: task.claimedBy ?? task.assignedTo ?? null,\n assignedToRoles: task.assignedToRoles ?? null,\n dueDate: options.deadlineAt,\n entityBindings: task.entityBindings ?? null,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n },\n { persistent: true }\n )\n } catch (error) {\n logger.error('Failed to emit task SLA event', {\n component: 'task-sla',\n taskId: task.id,\n eventId,\n err: error,\n })\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Task SLA scheduler \u2014 reminders and deadline breach for USER_TASK steps.\n *\n * `dueDate` has been display-only until now: nothing ever read it back, and the\n * authored `escalationRules` were dead config. This module makes the deadline\n * real, and it does so on the queue rather than with a poller \u2014 reusing the\n * Phase-3a absolute-deadline backstop the WAIT_FOR_CONDITION step already\n * relies on:\n *\n * - every job is enqueued ONCE, at task creation, with a delay;\n * - `deadlineAt` is ABSOLUTE and rides on the payload, so a queue running\n * behind cannot silently extend the deadline the author configured;\n * - the handler is IDEMPOTENT \u2014 a completed, cancelled or already-breached\n * task makes the job a no-op, and the breach is claimed with a conditional\n * UPDATE so at-least-once delivery still fires it exactly once.\n *\n * `computeTaskSlaSchedule` is PURE (no ORM, DI or registry imports) so the\n * scheduling arithmetic is unit-testable without a workflow.\n */\n\nimport type { EntityManager } from '@mikro-orm/core'\nimport type { AwilixContainer } from 'awilix'\nimport { StepInstance, UserTask, WorkflowInstance } from '../data/entities'\nimport type { TaskOnBreach, TaskReminder } from '../data/validators'\nimport { parseDuration } from './duration'\nimport { resolveTaskBreachHandling, type TaskBreachHandling } from './breach-routing'\nimport {\n findAgentReviewConfig,\n resolveAgentReviewBreachHandling,\n INVOKE_AGENT_ACTIVITY_TYPE,\n type AgentReviewBreachHandling,\n type AgentReviewStepLike,\n} from './agent-review'\nimport { findDefinitionForInstance } from './find-definition'\nimport { isRunClosedToUserTasks } from './instance-status'\nimport { emitWorkflowsEvent } from '../events'\nimport type * as stepHandlerModule from './step-handler'\nimport type * as transitionHandlerModule from './transition-handler'\nimport type * as workflowExecutorModule from './workflow-executor'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('workflows')\n\nexport type TaskSlaPhase = 'reminder' | 'breach'\n\nexport interface TaskSlaScheduleEntry {\n phase: TaskSlaPhase\n /** Absolute time this job should run. */\n fireAt: string\n /** Delay from `now`; never negative. */\n delayMs: number\n}\n\nexport interface TaskSlaSchedule {\n entries: TaskSlaScheduleEntry[]\n /** Reminder offsets that could not be parsed, so the caller can log them. */\n invalidOffsets: string[]\n}\n\n/** Statuses a task is still workable in \u2014 anything else makes an SLA job moot. */\nconst OPEN_TASK_STATUSES = ['PENDING', 'IN_PROGRESS'] as const\n\n/**\n * Resolve the jobs to enqueue for one task.\n *\n * A task with no deadline schedules NOTHING \u2014 reminders without a deadline have\n * nothing to count back from, so they are silently irrelevant rather than an\n * error. A reminder whose moment has already passed is dropped: firing it at\n * creation time would be noise, not a nudge. A reminder offset at or beyond the\n * whole deadline is likewise dropped \u2014 the breach job already speaks for that\n * moment.\n */\nexport function computeTaskSlaSchedule(options: {\n dueDate: Date | null | undefined\n reminders?: TaskReminder[] | null\n now?: Date\n}): TaskSlaSchedule {\n const { dueDate } = options\n if (!dueDate || Number.isNaN(dueDate.getTime())) return { entries: [], invalidOffsets: [] }\n\n const nowMs = (options.now ?? new Date()).getTime()\n const deadlineMs = dueDate.getTime()\n const entries: TaskSlaScheduleEntry[] = []\n const invalidOffsets: string[] = []\n\n for (const reminder of options.reminders ?? []) {\n const offset = reminder?.offset\n if (typeof offset !== 'string' || !offset.length) continue\n\n let offsetMs: number\n try {\n offsetMs = parseDuration(offset)\n } catch {\n invalidOffsets.push(offset)\n continue\n }\n\n if (offsetMs <= 0) continue\n const remindAtMs = deadlineMs - offsetMs\n if (remindAtMs <= nowMs || remindAtMs >= deadlineMs) continue\n\n entries.push({\n phase: 'reminder',\n fireAt: new Date(remindAtMs).toISOString(),\n delayMs: remindAtMs - nowMs,\n })\n }\n\n entries.push({\n phase: 'breach',\n fireAt: dueDate.toISOString(),\n delayMs: Math.max(0, deadlineMs - nowMs),\n })\n\n entries.sort((left, right) => left.delayMs - right.delayMs)\n\n return { entries, invalidOffsets }\n}\n\nexport interface ScheduleUserTaskSlaOptions {\n workflowInstanceId: string\n stepInstanceId: string\n branchInstanceId?: string | null\n userTaskId: string\n dueDate: Date | null | undefined\n reminders?: TaskReminder[] | null\n tenantId: string\n organizationId: string\n userId?: string\n now?: Date\n}\n\n/**\n * Enqueue every SLA job a freshly created task needs.\n *\n * Best-effort by contract: a queue failure must never break the workflow step\n * that created the task, exactly as the assignment event's emission does not.\n */\nexport async function scheduleUserTaskSla(\n options: ScheduleUserTaskSlaOptions\n): Promise<string[]> {\n const schedule = computeTaskSlaSchedule({\n dueDate: options.dueDate,\n reminders: options.reminders,\n now: options.now,\n })\n\n if (schedule.invalidOffsets.length) {\n logger.warn('Task reminder offsets skipped because they are not valid durations', {\n component: 'task-sla',\n taskId: options.userTaskId,\n invalidOffsets: schedule.invalidOffsets,\n })\n }\n\n if (schedule.entries.length === 0) return []\n\n const deadlineAt = options.dueDate ? options.dueDate.toISOString() : null\n if (!deadlineAt) return []\n\n const jobIds: string[] = []\n try {\n const { enqueueTaskSlaJob } = await import('./activity-executor')\n for (const entry of schedule.entries) {\n const jobId = await enqueueTaskSlaJob({\n workflowInstanceId: options.workflowInstanceId,\n stepInstanceId: options.stepInstanceId,\n branchInstanceId: options.branchInstanceId ?? null,\n userTaskId: options.userTaskId,\n phase: entry.phase,\n deadlineAt,\n fireAt: entry.fireAt,\n delayMs: entry.delayMs,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n userId: options.userId,\n })\n jobIds.push(jobId)\n }\n } catch (error) {\n logger.error('Failed to schedule task SLA jobs', {\n component: 'task-sla',\n taskId: options.userTaskId,\n err: error,\n })\n }\n\n return jobIds\n}\n\nexport type TaskSlaOutcome = 'noop' | 'reminded' | 'breached'\n\nexport interface RunTaskSlaJobOptions {\n userTaskId: string\n stepInstanceId: string\n workflowInstanceId: string\n branchInstanceId?: string | null\n phase: TaskSlaPhase\n deadlineAt: string\n tenantId: string\n organizationId: string\n userId?: string\n}\n\n/**\n * Run one SLA job.\n *\n * Every early return is a deliberate no-op rather than an error: the queue\n * retries, and a task that was completed in the meantime is the ordinary case,\n * not a failure.\n */\nexport async function runTaskSlaJob(\n em: EntityManager,\n container: AwilixContainer,\n options: RunTaskSlaJobOptions\n): Promise<TaskSlaOutcome> {\n const { tenantId, organizationId } = options\n\n const task = await em.findOne(UserTask, {\n id: options.userTaskId,\n tenantId,\n organizationId,\n })\n\n if (!task) return 'noop'\n if (!OPEN_TASK_STATUSES.includes(task.status as (typeof OPEN_TASK_STATUSES)[number])) return 'noop'\n\n const deadlineMs = Date.parse(options.deadlineAt)\n if (Number.isNaN(deadlineMs)) return 'noop'\n\n if (options.phase === 'reminder') {\n // A reminder that arrives after the deadline has nothing left to warn\n // about, and one for an already-breached task is strictly noise.\n if (task.escalatedAt) return 'noop'\n if (Date.now() >= deadlineMs) return 'noop'\n\n await logTaskSlaEvent(em, container, options, 'USER_TASK_REMINDER_DUE', {\n taskId: task.id,\n taskName: task.taskName,\n dueDate: options.deadlineAt,\n })\n\n await emitTaskSlaEvent(em, 'workflows.task.reminder_due', task, options)\n return 'reminded'\n }\n\n // Claim the breach with a conditional UPDATE rather than a read-then-write:\n // the queue delivers at least once, and two deliveries reading `escalated_at\n // IS NULL` would both fire. The loser updates zero rows and no-ops.\n const now = new Date()\n const claimed = await em.nativeUpdate(\n UserTask,\n {\n id: task.id,\n tenantId,\n organizationId,\n status: { $in: [...OPEN_TASK_STATUSES] },\n escalatedAt: null,\n },\n { escalatedAt: now, updatedAt: now }\n )\n\n if (claimed === 0) return 'noop'\n\n task.escalatedAt = now\n task.updatedAt = now\n\n // WHICH path the breach took is part of the audit trail, not an implementation\n // detail: \"the deadline passed\" and \"the deadline passed and the run was\n // routed away from this task\" are different facts about the same instant.\n const handling = await applyBreachHandling(em, container, task, options, now)\n\n await logTaskSlaEvent(em, container, options, 'USER_TASK_DEADLINE_BREACHED', {\n taskId: task.id,\n taskName: task.taskName,\n dueDate: options.deadlineAt,\n breachedAt: now.toISOString(),\n onBreach: handling.outcome,\n ...(handling.detail ?? {}),\n })\n\n await emitTaskSlaEvent(em, 'workflows.task.deadline_breached', task, options)\n\n // A reassignment only means anything once the new owner is told about it, and\n // the assignment event is the one path that already reaches both an individual\n // and a role queue.\n if (handling.outcome === 'reassigned') {\n await emitTaskSlaEvent(em, 'workflows.task.assigned', task, options)\n }\n\n if (handling.resumeInstance) {\n await resumeAfterBreachRoute(em, container, task, options, handling.resumeInstance)\n }\n\n return 'breached'\n}\n\ntype BreachOutcome =\n | 'none'\n | 'notified'\n | 'reassigned'\n | 'routed'\n | 'route_skipped_branch'\n | 'route_skipped_closed_run'\n | 'attention'\n | 'attention_skipped_closed_run'\n\ninterface AppliedBreachHandling {\n outcome: BreachOutcome\n detail?: Record<string, unknown>\n resumeInstance?: { stepId: string; toStepId: string; transitionId?: string }\n}\n\ntype BreachResolution = TaskBreachHandling | AgentReviewBreachHandling\n\n/**\n * A step whose breached task is an agent DISPOSITION task rather than an\n * authored USER_TASK. Recognised structurally from the compiled step (an\n * AUTOMATED step carrying an INVOKE_AGENT activity), which is the same shape\n * `graph-utils` round-trips the node from.\n */\nfunction isInvokeAgentStepDef(step: AgentReviewStepLike | undefined): boolean {\n return (step?.activities ?? []).some(\n (activity) => activity?.activityType === INVOKE_AGENT_ACTIVITY_TYPE\n )\n}\n\n/**\n * Apply the resolver's answer to the task row. Routing itself is deferred to\n * `resumeAfterBreachRoute` so the workflow only advances after the breach has\n * been recorded and announced.\n *\n * Which resolver answers depends on WHAT the task is. An authored USER_TASK\n * keeps the full \u00A76.1 vocabulary, including \"follow a route\". An agent\n * disposition task (\u00A77.5) gets the escalate-only one: notify, reassign, or mark\n * the run for attention. It can reach neither a verdict nor a route, because\n * both would resolve a pending proposal without a human \u2014 routing the run past\n * a proposal nobody answered silently drops the mutation the agent proposed,\n * which is a rejection in everything but name. A hand-authored `kind:'slaBreach'`\n * transition on an agent step therefore cannot fire either: the branch below is\n * taken from the STEP's shape, not from the config the author supplied.\n */\nasync function applyBreachHandling(\n em: EntityManager,\n container: AwilixContainer,\n task: UserTask,\n options: RunTaskSlaJobOptions,\n now: Date\n): Promise<AppliedBreachHandling> {\n let resolution: BreachResolution = { kind: 'none' }\n let stepId: string | null = null\n let instance: WorkflowInstance | null = null\n\n try {\n const stepInstance = await em.findOne(StepInstance, {\n id: options.stepInstanceId,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n })\n stepId = stepInstance?.stepId ?? null\n\n instance = await em.findOne(WorkflowInstance, {\n id: options.workflowInstanceId,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n })\n\n if (stepId && instance) {\n const definition = await findDefinitionForInstance(em, instance)\n const stepDef = (definition?.definition?.steps ?? []).find(\n (step: { stepId?: string }) => step.stepId === stepId\n ) as\n | ({ userTaskConfig?: { onBreach?: TaskOnBreach | null } } & AgentReviewStepLike)\n | undefined\n\n resolution = isInvokeAgentStepDef(stepDef)\n ? resolveAgentReviewBreachHandling(findAgentReviewConfig(stepDef)?.onBreach ?? null)\n : resolveTaskBreachHandling(\n definition?.definition ?? null,\n stepId,\n stepDef?.userTaskConfig?.onBreach ?? null\n )\n }\n } catch (error) {\n // A definition the engine can no longer read must not strand the breach:\n // the task is already marked escalated and the event still fires.\n logger.error('Failed to resolve breach handling; falling back to notify-only', {\n component: 'task-sla',\n taskId: task.id,\n err: error,\n })\n return { outcome: 'none' }\n }\n\n if (resolution.kind === 'notify') return { outcome: 'notified' }\n\n if (resolution.kind === 'attention') {\n if (!instance) return { outcome: 'none' }\n if (isRunClosedToUserTasks(instance.status)) {\n return { outcome: 'attention_skipped_closed_run', detail: { stepId, status: instance.status } }\n }\n await markInstanceForAttention(em, instance, stepId, task, now)\n return { outcome: 'attention', detail: { stepId } }\n }\n\n if (resolution.kind === 'reassign') {\n task.assignedTo = resolution.assignedTo\n task.assignedToRoles = resolution.assignedToRoles\n task.claimedBy = null\n task.claimedAt = null\n task.status = 'PENDING'\n task.escalatedTo = resolution.assignedTo ?? resolution.assignedToRoles?.[0] ?? null\n task.reassignedAt = now\n task.reassignReason = 'sla_breach'\n task.updatedAt = now\n await em.flush()\n return {\n outcome: 'reassigned',\n detail: {\n reassignedTo: resolution.assignedTo,\n reassignedToRoles: resolution.assignedToRoles,\n },\n }\n }\n\n if (resolution.kind === 'route') {\n const toStepId = resolution.transition.toStepId\n if (!stepId || !toStepId) return { outcome: 'none' }\n\n // A branch-scoped task does NOT follow its breach route, for the same\n // reason a decision button does not select a route inside a branch\n // (`completeUserTask`): a branch advances through `resumeBranch` with its\n // own token, and overriding that is a parallel-execution change rather than\n // a task-surface one.\n if (options.branchInstanceId) {\n return { outcome: 'route_skipped_branch', detail: { transitionId: resolution.transition.transitionId } }\n }\n\n if (isRunClosedToUserTasks(instance?.status)) {\n return {\n outcome: 'route_skipped_closed_run',\n detail: { transitionId: resolution.transition.transitionId, status: instance?.status },\n }\n }\n\n // The task is superseded by the route, not abandoned: `ESCALATED` is an\n // existing `UserTaskStatus`, so no state machine changes.\n task.status = 'ESCALATED'\n task.updatedAt = now\n await em.flush()\n\n return {\n outcome: 'routed',\n detail: { transitionId: resolution.transition.transitionId, toStepId },\n resumeInstance: { stepId, toStepId, transitionId: resolution.transition.transitionId },\n }\n }\n\n return { outcome: 'none' }\n}\n\n/**\n * Mark a run for triage without touching its state machine.\n *\n * The instance is ALREADY paused \u2014 an agent step parks on the proposal-ready\n * signal \u2014 so this writes only the engine-owned `metadata.attention` marker the\n * failure queue already uses (`GET /api/workflows/instances?attention=true`\n * lists it). Deliberately no status write, no `errorMessage`: the run is not\n * failing, it is waiting on a person who is late, and the proposal is still\n * pending for whoever picks it up.\n */\nasync function markInstanceForAttention(\n em: EntityManager,\n instance: WorkflowInstance,\n stepId: string | null,\n task: UserTask,\n now: Date\n): Promise<void> {\n instance.metadata = {\n ...(instance.metadata || {}),\n attention: {\n reason: 'DISPOSITION_SLA_BREACH',\n ...(stepId ? { stepId } : {}),\n at: now.toISOString(),\n },\n }\n instance.updatedAt = now\n await em.flush()\n\n logger.warn('Agent disposition deadline passed; run marked for attention', {\n component: 'task-sla',\n taskId: task.id,\n workflowInstanceId: instance.id,\n stepId,\n })\n}\n\n/**\n * Follow the breach route: exit the task's step and drive the run onward,\n * mirroring how `fireTimer` resumes a run parked on a WAIT_FOR_TIMER step.\n *\n * Best-effort: a routing failure leaves the run exactly where it was, with the\n * breach already recorded, rather than throwing the queue job into a retry loop\n * that would re-announce a breach that has already fired.\n */\nasync function resumeAfterBreachRoute(\n em: EntityManager,\n container: AwilixContainer,\n task: UserTask,\n options: RunTaskSlaJobOptions,\n route: { stepId: string; toStepId: string; transitionId?: string }\n): Promise<void> {\n try {\n const instance = await em.findOne(WorkflowInstance, {\n id: options.workflowInstanceId,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n })\n if (!instance) return\n\n const stepHandler = container.resolve<typeof stepHandlerModule>('stepHandler')\n const transitionHandler = container.resolve<typeof transitionHandlerModule>('transitionHandler')\n const workflowExecutor = container.resolve<typeof workflowExecutorModule>('workflowExecutor')\n\n const stepInstance = await em.findOne(StepInstance, {\n id: options.stepInstanceId,\n status: 'ACTIVE',\n })\n if (stepInstance) {\n await stepHandler.exitStep(em, stepInstance, {\n userTaskId: task.id,\n slaBreached: true,\n })\n }\n\n const transitionResult = await transitionHandler.executeTransition(\n em,\n container,\n instance,\n route.stepId,\n route.toStepId,\n {\n workflowContext: instance.context,\n userId: options.userId,\n transitionId: route.transitionId,\n },\n )\n\n if (!transitionResult.success) {\n logger.error('SLA-breach transition failed', {\n component: 'task-sla',\n taskId: task.id,\n err: transitionResult.error,\n })\n return\n }\n\n await workflowExecutor.executeWorkflow(em, container, instance.id, { userId: options.userId })\n } catch (error) {\n logger.error('Failed to follow the SLA-breach route', {\n component: 'task-sla',\n taskId: task.id,\n err: error,\n })\n }\n}\n\nasync function logTaskSlaEvent(\n em: EntityManager,\n container: AwilixContainer,\n options: RunTaskSlaJobOptions,\n eventType: string,\n eventData: Record<string, unknown>\n): Promise<void> {\n try {\n const eventLogger = container.resolve<{\n logWorkflowEvent: (\n em: EntityManager,\n event: Record<string, unknown>\n ) => Promise<unknown>\n }>('eventLogger')\n\n await eventLogger.logWorkflowEvent(em, {\n workflowInstanceId: options.workflowInstanceId,\n stepInstanceId: options.stepInstanceId,\n ...(options.branchInstanceId ? { branchInstanceId: options.branchInstanceId } : {}),\n eventType,\n eventData,\n userId: options.userId,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n })\n } catch (error) {\n logger.error('Failed to log task SLA workflow event', {\n component: 'task-sla',\n taskId: options.userTaskId,\n eventType,\n err: error,\n })\n }\n}\n\nasync function emitTaskSlaEvent(\n em: EntityManager,\n eventId: 'workflows.task.reminder_due' | 'workflows.task.deadline_breached' | 'workflows.task.assigned',\n task: UserTask,\n options: RunTaskSlaJobOptions\n): Promise<void> {\n try {\n const instance = await em.findOne(WorkflowInstance, {\n id: options.workflowInstanceId,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n })\n\n await emitWorkflowsEvent(\n eventId,\n {\n taskId: task.id,\n taskName: task.taskName,\n workflowInstanceId: options.workflowInstanceId,\n workflowId: instance?.workflowId ?? null,\n workflowName: instance?.workflowId ?? '',\n assignedUserId: task.claimedBy ?? task.assignedTo ?? null,\n assignedToRoles: task.assignedToRoles ?? null,\n dueDate: options.deadlineAt,\n entityBindings: task.entityBindings ?? null,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n },\n { persistent: true }\n )\n } catch (error) {\n logger.error('Failed to emit task SLA event', {\n component: 'task-sla',\n taskId: task.id,\n eventId,\n err: error,\n })\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,6 +1,7 @@
1
1
  import { LockMode } from "@mikro-orm/core";
2
- import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
2
+ import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
3
3
  import {
4
+ UserTask,
4
5
  WorkflowInstance,
5
6
  WorkflowEvent
6
7
  } from "../data/entities.js";
@@ -999,6 +1000,18 @@ async function completeWorkflow(em, container, instanceId, status, result) {
999
1000
  }
1000
1001
  }
1001
1002
  }
1003
+ const cancelledTasks = status === "CANCELLED" ? await findWithDecryption(
1004
+ em,
1005
+ UserTask,
1006
+ {
1007
+ workflowInstanceId: instance.id,
1008
+ tenantId: instance.tenantId,
1009
+ organizationId: instance.organizationId,
1010
+ status: { $in: ["PENDING", "IN_PROGRESS"] }
1011
+ },
1012
+ void 0,
1013
+ { tenantId: instance.tenantId, organizationId: instance.organizationId }
1014
+ ) : [];
1002
1015
  const now = /* @__PURE__ */ new Date();
1003
1016
  instance.status = status;
1004
1017
  instance.updatedAt = now;
@@ -1021,7 +1034,22 @@ async function completeWorkflow(em, container, instanceId, status, result) {
1021
1034
  break;
1022
1035
  }
1023
1036
  await writeRunOutcome(em, instance, status);
1037
+ for (const task of cancelledTasks) {
1038
+ task.status = "CANCELLED";
1039
+ task.updatedAt = now;
1040
+ }
1024
1041
  await em.flush();
1042
+ for (const task of cancelledTasks) {
1043
+ await logWorkflowEvent(em, {
1044
+ workflowInstanceId: instanceId,
1045
+ stepInstanceId: task.stepInstanceId,
1046
+ ...task.branchInstanceId ? { branchInstanceId: task.branchInstanceId } : {},
1047
+ eventType: "USER_TASK_CANCELLED",
1048
+ eventData: { taskId: task.id, taskName: task.taskName, reason: "workflow-cancelled" },
1049
+ tenantId: instance.tenantId,
1050
+ organizationId: instance.organizationId
1051
+ });
1052
+ }
1025
1053
  const eventType = status === "COMPLETED" ? "WORKFLOW_COMPLETED" : status === "FAILED" ? "WORKFLOW_FAILED" : "WORKFLOW_CANCELLED";
1026
1054
  await logWorkflowEvent(em, {
1027
1055
  workflowInstanceId: instanceId,