Skip to content

Engine, approvals & SLA

The runtime lives in Genie.Engine/Features/Workflow. IWorkflowEngine (implemented by WorkflowEngine) orchestrates the lifecycle; focused collaborators handle approvals (WorkflowApprovalResolver), guards (WorkflowExpressionEvaluator), and background SLA checks (WorkflowSlaMonitor + its hosted service). This page describes how a definition actually runs. For the XML that drives it, see Authoring a workflow.

StartAsync(workflowName, entityType, entityId, contextData, companyId?):

  1. Loads the active definition by Name, parses its XML, and finds the Start node.
  2. Pins the version — records the definition’s current VersionHash on the new instance so later edits never change how this run behaves (minting a first version lazily for pre-versioning definitions). See Versioning & company scoping.
  3. Serialises contextData to the instance’s ContextData (JSON) — every @variable the workflow reads later comes from here.
  4. Stamps the tenant — every instance query (the workflow grids, WF Tasks, pending approvals, and the entity lookup behind transitionEntityWorkflow) filters CompanyId = @SessionCompanyId, so the instance and its state rows carry: the explicit companyId argument when supplied (programmatic hosts pick the tenant in code), else the session company for authenticated starts, else the definition’s company for anonymous starts (e.g. a public-API host calling IWorkflowEngine.StartAsync directly).
  5. Creates the WorkflowInstance (Status = "Active") and its initial WorkflowInstanceState, then auto-advances from Start through its single outgoing transition.

From there the engine calls AdvanceAsync for each edge it follows. AdvanceAsync:

  • closes the current state (Completed, ExitedAt stamped) and writes a WorkflowTransitionLog row,
  • moves CurrentNodeKey to the target and opens a new state,
  • resolves node assignment (from node attributes, else inherited from the instance),
  • applies the transition’s sticky FlowStatus when non-empty,
  • if the target is End, sets the instance Completed and clears assignment,
  • runs the bound-record update then the transition’s <Sql>, and saves — see below,
  • then auto-advances again when the target is a Decision or Action node.

When the Start node declares a <Binding> or the edge carries a <Sql>, AdvanceAsync runs them in one transaction with the instance save:

  1. the bound record’s UPDATE (skipped when the transition carries no status),
  2. the transition’s <Sql> — so it can extend or override what the binding just wrote,
  3. SaveChangesAsync, then commit.

A failure anywhere in that sequence rolls the whole transition back: the instance keeps its previous node and FlowStatus, no WorkflowTransitionLog row is written, and the exception surfaces through the standard error envelope. Two consequences worth knowing when authoring:

  • Your SQL runs before the instance row is committed, so read the supplied parameters (@EntityId, @FlowStatus, @TransitionLabel, @Comment, context variables) rather than querying Workflow.Instances for the new state.
  • A transaction is only opened when there is authored SQL to run and none is already ambient — a caller that already owns one (e.g. a form submit whose SQL fired a workflow callback) keeps its own.

A binding whose UPDATE matches no row is logged as a warning, not an error: the flow’s own state is still valid, and it almost always means the binding points at the wrong entity, primary key, or tenant. The tenant predicate uses the instance’s own CompanyId, not the ambient session, so auto-advance and background work (SLA escalation, hosted services) scope correctly with no session. Both this and Action-node ExecuteSql go through the provider-agnostic ADO path, so authored SQL runs unchanged on SQL Server and PostgreSQL and literal braces need no escaping.

Approval and Standard nodes pause — the instance waits for an external TransitionAsync or SubmitApprovalAsync call.

TransitionAsync(instanceId, transitionLabel, …) finds the matching outgoing transition (by Label, falling back to To), evaluates its Expression guard, optionally merges caller data into the context, and advances. CancelAsync sets the instance Cancelled.

When the engine enters an Approval node it creates one pending WorkflowApproval record per approver rule:

  • Role — one record per role; any member can claim and decide it. The instance is also assigned to that role (looked up in [Identity].[Roles]) so it appears in the role’s queue.
  • User — one record for a specific user id.
  • FieldValue — resolves a user id from a @ContextVar.

Approvers act through the built-in Pending Approvals list and the WF_ApprovalForm (the Approval node defaults its activity to that form) — no custom form required. A decision comes in through SubmitApprovalAsync(instanceId, nodeKey, userId, decision, comment).

After each decision, CheckApprovalCompletionAsync evaluates the node’s ApprovalConfig.Type against the pending / approved / rejected records created since the node was last entered:

Type Approves when… Rejects when…
Any the first approval arrives (remaining pending records are marked Ignored). the first rejection arrives.
Sequential (default) all records are approved and none pending. any rejection arrives (immediately).
Parallel all have decided and none rejected. all have decided and at least one rejected.

When the gate is satisfied the engine follows OnApprove / OnReject — matching those values against the transition Labels leaving the node — and calls AdvanceAsync.

Action nodes execute by ActionType (case-insensitive), then auto-advance:

  • ExecuteSql — the <Payload> SQL runs on the same provider-agnostic path as transition <Sql>, parameterised with @RecordId and @EntityId (both the entity id), @InstanceId, @FlowStatus, the session parameters, and every context variable (@ContextVar). Context keys that aren’t valid SQL parameter names (e.g. containing spaces) are skipped.
  • AppNotification — parses <To>/<Message>, renders both with Handlebars over the context, resolves recipients to usernames, and inserts NotificationStore rows (Type = App) delivered over SignalR/web-push.
  • SendEmail — parses <To>/<CC>/<Subject>/<MailBody>, renders all four with Handlebars (so {{Email}}-style recipients from context work), then resolves recipient emails and queues a NotificationStore row (Type = Email) for the background email worker. Delivery requires SMTP configured under Genie:Notifications:Email.

Recipient tokens (Role:Name, @ContextVar, literal email) are described in Authoring — Action node. See also Notifications for delivery workers and channels.

IWorkflowActionHandler (ActionType + ExecuteAsync) is the pluggable seam for adding further action types.

WorkflowExpressionEvaluator guards transitions (and drives Decision branching). It is a small, eval-free interpreter — not a SQL or C# engine:

  • Operators: ==, !=, >, <, >=, <=, IS NULL, IS NOT NULL.
  • Boolean composition: AND, OR (split outside single-quoted literals).
  • Literals: true / false; single/double-quoted strings; numbers.
  • Operands: @Var or bare Var (both resolved against the context, with and without the @).
  • Typing: comparisons parse both sides as decimals and compare numerically; equality compares string forms case-insensitively.
  • Fail-safe: an empty expression is true; an expression it can’t parse is false.

@FlowStatus is injected into the context for evaluation, so guards can branch on the business status.

WorkflowSlaMonitorHostedService is a MonitoredBackgroundService that runs every 1 minute and calls IWorkflowSlaMonitor.CheckAndEscalateAsync. It appears on the hosted-services dashboard as Workflow SLA Monitor, can be paused there, waits for model migration before its first sweep, and retries a failed sweep after a minute. A host can supply its own SLA policy by registering an IWorkflowSlaMonitor after AddGenieWorkflow(); the last registration wins.

The designer API — /api/v1/genie/workflow

Section titled “The designer API — /api/v1/genie/workflow”

WorkflowDesignerController ([Authorize], base route /api/v1/genie/workflow) backs the visual designer and the runtime hooks. At a high level:

Definitions

  • GET definitions — list active definitions (company-scoped).
  • GET definitions/{key} — a definition by id, name, or slug (includes its XML + VersionHash).
  • POST definitions / PUT definitions/{id} — create / update metadata.
  • POST definitions/{definitionId}/save — save XML (validates a Start node exists; versions the save).

Version history

  • GET definitions/{definitionId}/versions — every saved version (hash · time · notes · IsCurrent).
  • GET definitions/{definitionId}/versions/{versionHash} — a single version’s XML.

Instances

  • POST instances/start, instances/{id}/transition, instances/{id}/approve, instances/{id}/cancel — for programmatic and administrative callers only; the React client has no methods for these, because authored SQL drives workflows through server callbacks instead. The three that act on an existing instance require the caller to be its assignee, a member of its assigned role, a pending approver on it, or hold System/Admin; the acting user always comes from the session.
  • GET instances/{id}, instances/{id}/audit, instances/{id}/transitions.
  • GET instances/{id}/preview — the graph the instance actually runs (its pinned XML), visited nodes, traversed paths, per-node context snapshots, and available transitions — for the designer’s instance overlay.
  • POST instances/retry — re-attempt a workflow start for a wizard form response that failed to start (rehydrating context from the stored ResponseData).

The React designer (components/workflow/GenieWorkflowDesigner.tsx) renders the node/transition graph, edits action payloads structurally, and overlays live instance state via the preview endpoint.

The right-hand pane is the same property grid as the report designer’s Properties tab. A chip at the top names the selection — WORKFLOW for the workflow settings (nothing selected), TRANSITION, the node’s type (START, APPROVAL, …), or 3 NODES for a multi-selection — and below it one bordered box lists every property as a two-column row under an uppercase category:

  • Workflow settings — Identity (Name, Slug, Label), Classification (Entity Type, Category).
  • Transition — Identity (Label, Order), Guard (Expression, Require Comment), Status (Flow Status, Entity Status), SQL.
  • Node — Identity (Key, read-only; Label; Type; Description), then per type: Activity and SLA (Standard / Approval), Approval (Mode) with an Approvers list, Assignment, Bound record (Start), or Action with its payload rows, recipient lists and the raw-payload disclosure. A Decision lists its Branch conditions (the target above a full-width guard input); other nodes list their Outgoing transitions — click one to select that transition.

Flags are — (unset) / true / false selects. The gating ones — SLA Enabled and Bind Record — reveal their dependent rows when true; unset or false removes the <Sla> / <Binding> from the XML, exactly as before. A description footer under the pane explains the focused row (it starts on the selection’s first property). Delete node / Delete transition / Delete N nodes sits below the box. While viewing an old version or an instance preview every editor is disabled and the add, remove and delete buttons are hidden. Drag the splitter to widen the pane.

Ctrl+Z steps back through your edits; Ctrl+Shift+Z or Ctrl+Y replays them. The toolbar carries the same two buttons, and each tooltip names what it would reverse. A drag is one step, a run of typing in one property is one step, and loading XML (the XML dialog, or an assistant Apply) is one step. Ctrl+Z inside a text box or the XML editor belongs to that editor. A dot on Save marks unsaved changes, and leaving the page with any asks first. Opening an old version from History starts a fresh stack, so an old version can never be undone into the live workflow.

When the AI Assistant module is on, attach the workflow from the composer’s + menu (Current workflow) and two tools scoped to this page become available:

  • workflow_get_definition reads the saved workflow under your own permissions, together with a condensed reference of the <Workflow> elements and attributes, so the model writes from the contract rather than guessing from what the open workflow happens to use.
  • workflow_apply_change proposes a complete rewritten <Workflow> document as an Apply card in the chat.

Clicking Apply loads the proposal onto the canvas as one undoable step; Ctrl+Z reverses it. From there it is an ordinary unsaved edit — review it, then Save.

Apply keeps your layout. Nodes that already exist stay exactly where they are on your canvas (position and size), and existing transitions keep their label position and connection sides. A new node is placed beside the node its incoming transition comes from, clear of every other node. The flip side: asking the assistant to move existing nodes or tidy the canvas does nothing, so arrange nodes by hand. The XML dialog’s Load is not adjusted; it loads exactly what you paste.

  • The assistant never writes. Apply fills the canvas; saving stays your action. Each tool re-checks Update on the Wf_DefinitionsView resource itself.
  • A proposal is validated before you see it. It runs the same checks Save does (it parses, has a Start node, and its binding validates) plus a few Save cannot catch: exactly one Start node, unique node keys, transitions between existing nodes, OnApprove/OnReject naming a transition that leaves that node, and an unchanged Name. When the model gets it wrong, the validator’s message goes back to it and it corrects itself.
  • There is no data preview. Workflow SQL runs with runtime values substituted in, and some of it writes, so the model probes tables with execute_query instead of executing your SQL.
  • Apply needs this page open, and is refused while you are viewing an old version or an instance preview.
  • “Reply in chat” — or “don’t change the workflow” — answers in the conversation and withholds the apply tool for that turn.

The assistant reads the saved definition, so Save before asking if the canvas has changes you want it to see.