Skip to content

Workflows

The imbi.common.workflows package holds the workflow definitions that imbi-api and the workflow engine (imbi-workflows) share. See ADR 0021 for the design and Workflows for the operator view, including the table of step types.

Module What it holds
graph The models of a workflow version: Graph, Node, Edge, Settings, and the validation result
step_type StepType, the declaration of one step type
registry One module for each step type, and STEP_TYPES
validation validate(), the checks of a version
walk The order of the steps of a run. The engine and the test run use it
runtime The workflow_runtime Postgres schema helpers, such as append_event()
history The run log in ClickHouse, published through Iggy

Step types

registry.STEP_TYPES maps each type name to its StepType. Each module in registry declares one STEP. A node with a type that is not in STEP_TYPES fails validation.

A StepType declares:

Field Meaning
type, category, label, description The name, the palette group (trigger, logic, action, agent) and the text in the editor
params, output Pydantic models for the parameters and the output. The API serves their JSON schemas
sample_output An example output that the editor shows
ports The output port names. ('',) is one port. None means one port for each entry in params.branches
trigger The type starts a run
side_effect, idempotent The type changes something outside the run, and a second call is safe
timeout_seconds, retries The defaults. A node can set its own
required_permissions A function from the node parameters to the permissions that the activating user must have

When a call of a step that has side_effect=True and idempotent=False stops before its result is recorded, the engine does not call it again. The step fails with outcome_unknown.

To add a step type:

  1. Add a module to libraries/common/src/imbi/common/workflows/registry/ that declares STEP.
  2. Add the module to STEP_TYPES in registry/__init__.py.
  3. Add the executor to Executors in apps/workflows/src/imbi/workflows/executors.py. Add a type to the registry only when the engine can run it.

Validation

validate(graph, settings) returns a ValidationResult with a list of issues. A missing required parameter is a warning, so a draft can have steps that are not configured. Every other issue is an error. The API saves a version with its issues, but it activates only a version with no issues. The API refuses to save a version that breaks one of the size limits below (LIMIT_CODES).

Limit Value
Steps 100 (MAX_NODES)
Connections 200 (MAX_EDGES)
Graph size, as JSON 65536 bytes (MAX_GRAPH_BYTES)

Run history

The engine and imbi-api write each run event to Postgres in the run's transaction. After the commit, history publishes the event to the workflow_run_events Iggy stream and the run state to workflow_runs. A failed publish does not fail the write. The imbi-api archive sweep publishes again the rows that ClickHouse does not have.

API Reference

Graph

Graph

Bases: BaseModel

Node

Bases: BaseModel

Edge

Bases: BaseModel

An edge from output port of source to target.

Settings

Bases: BaseModel

ValidationIssue

Bases: BaseModel

ValidationResult

Bases: BaseModel

Step types

StepType dataclass

StepType(
    type: str,
    category: Category,
    label: str,
    description: str,
    params: type[BaseModel],
    output: type[BaseModel],
    sample_output: dict[str, Any],
    ports: tuple[str, ...] | None = ('',),
    trigger: bool = False,
    side_effect: bool = False,
    idempotent: bool = True,
    timeout_seconds: int | None = 300,
    retries: int = 0,
    required_permissions: Callable[
        [dict[str, Any]], set[str]
    ] = _no_permissions,
)

One step type. Each module in :mod:.registry declares one.

describe

describe() -> dict[str, typing.Any]

Return the step type as the API serves it.

Source code in libraries/common/src/imbi/common/workflows/step_type.py
def describe(self) -> dict[str, typing.Any]:
    """Return the step type as the API serves it."""
    return {
        'type': self.type,
        'category': self.category,
        'label': self.label,
        'description': self.description,
        'trigger': self.trigger,
        'ports': None if self.ports is None else list(self.ports),
        'params_schema': self.params.model_json_schema(),
        'output_schema': self.output.model_json_schema(),
        'sample_output': self.sample_output,
        'side_effect': self.side_effect,
        'idempotent': self.idempotent,
        'timeout_seconds': self.timeout_seconds,
        'retries': self.retries,
    }

ports_for

ports_for(params: dict[str, Any]) -> list[str]

Return the output ports of a node of this type.

Source code in libraries/common/src/imbi/common/workflows/step_type.py
def ports_for(self, params: dict[str, typing.Any]) -> list[str]:
    """Return the output ports of a node of this type."""
    if self.ports is not None:
        return list(self.ports)
    raw: typing.Any = params.get('branches')
    branches: list[typing.Any] = raw if isinstance(raw, list) else []
    return [str(b) for b in branches]

STEP_TYPES module-attribute

STEP_TYPES: dict[str, StepType] = {
    (type): (STEP)
    for module in (
        manual,
        schedule,
        webhook,
        wait,
        model_decision,
        email,
        deploy,
        rollback,
        agent,
    )
}

Validation

validate

validate(
    wf_graph: Graph, settings: Settings
) -> graph.ValidationResult

Return the issues of a workflow version.

Source code in libraries/common/src/imbi/common/workflows/validation.py
def validate(
    wf_graph: graph.Graph, settings: graph.Settings
) -> graph.ValidationResult:
    """Return the issues of a workflow version."""
    issues = _Issues()
    if len(wf_graph.nodes) > MAX_NODES:
        issues.add('error', 'too_many_nodes', f'More than {MAX_NODES} steps')
    if len(wf_graph.edges) > MAX_EDGES:
        issues.add(
            'error', 'too_many_edges', f'More than {MAX_EDGES} connections'
        )
    if len(wf_graph.model_dump_json().encode()) > MAX_GRAPH_BYTES:
        issues.add(
            'error',
            'graph_too_large',
            f'The graph is larger than {MAX_GRAPH_BYTES} bytes',
        )
    if not issues:
        _Validator(wf_graph, issues).run()
        _check_concurrency(wf_graph, settings, issues)
    return graph.ValidationResult(issues=issues)

Walk

order

order(wf_graph: Graph) -> list[str]

Return the node ids in topological order.

Ties keep the order of wf_graph.nodes. Nodes on a cycle are not in the result; activation refuses a graph with a cycle.

Source code in libraries/common/src/imbi/common/workflows/walk.py
def order(wf_graph: graph.Graph) -> list[str]:
    """Return the node ids in topological order.

    Ties keep the order of ``wf_graph.nodes``. Nodes on a cycle are not
    in the result; activation refuses a graph with a cycle.
    """
    pending = {n.id: 0 for n in wf_graph.nodes}
    for edge in wf_graph.edges:
        if edge.target in pending:
            pending[edge.target] += 1
    result = [n for n, count in pending.items() if count == 0]
    for node_id in result:
        for edge in wf_graph.edges:
            if edge.source == node_id and edge.target in pending:
                pending[edge.target] -= 1
                if pending[edge.target] == 0:
                    result.append(edge.target)
    return result

advance

advance(
    wf_graph: Graph,
    status: dict[str, str],
    ports: dict[str, int | None],
) -> tuple[list[str], list[str]]

Return the ready steps and the steps that become skipped.

status is changed in place for the skipped steps. A pending node with no incoming edge is a trigger that did not start the run; the caller marks those skipped when the run starts.

Source code in libraries/common/src/imbi/common/workflows/walk.py
def advance(
    wf_graph: graph.Graph,
    status: dict[str, str],
    ports: dict[str, int | None],
) -> tuple[list[str], list[str]]:
    """Return the ready steps and the steps that become ``skipped``.

    ``status`` is changed in place for the skipped steps. A pending node
    with no incoming edge is a trigger that did not start the run; the
    caller marks those ``skipped`` when the run starts.
    """
    nodes = order(wf_graph)
    parents: dict[str, list[graph.Edge]] = {n: [] for n in nodes}
    for edge in wf_graph.edges:
        if edge.target in parents:
            parents[edge.target].append(edge)
    joins = {n.id: n.join for n in wf_graph.nodes}
    skipped: list[str] = []
    ready: list[str] = []
    # Topological order: a skip reaches every step after it in one pass.
    for node_id in nodes:
        if status.get(node_id) != 'pending' or not parents[node_id]:
            continue
        states = [_edge_state(e, status, ports) for e in parents[node_id]]
        if all(s == 'inactive' for s in states):
            status[node_id] = 'skipped'
            skipped.append(node_id)
        elif 'active' in states and (
            joins[node_id] == 'any' or 'waiting' not in states
        ):
            ready.append(node_id)
    return ready, skipped

Runtime

append_event async

append_event(
    conn: AsyncConnection[Any],
    run_id: UUID,
    event_type: str,
    payload: dict[str, Any],
) -> None

Write the next event of a run. Call it in the run's transaction.

The UPDATE of last_seq locks the run row, so the sequence has no gaps.

Source code in libraries/common/src/imbi/common/workflows/runtime.py
async def append_event(
    conn: psycopg.AsyncConnection[typing.Any],
    run_id: uuid.UUID,
    event_type: str,
    payload: dict[str, typing.Any],
) -> None:
    """Write the next event of a run. Call it in the run's transaction.

    The ``UPDATE`` of ``last_seq`` locks the run row, so the sequence
    has no gaps.
    """
    cur = await conn.execute(
        'UPDATE workflow_runtime.runs SET last_seq = last_seq + 1'
        ' WHERE id = %s RETURNING last_seq',
        (run_id,),
    )
    row = await cur.fetchone()
    if row is None:
        raise LookupError(f'No run {run_id}')
    await conn.execute(
        'INSERT INTO workflow_runtime.run_events'
        ' (run_id, seq, event_id, type, payload)'
        ' VALUES (%s, %s, %s, %s, %s)',
        (run_id, row[0], uuid.uuid4(), event_type, pg_json.Jsonb(payload)),
    )

pause_bindings async

pause_bindings(
    conn: AsyncConnection[Any], workflow_id: UUID
) -> None

Disable the trigger bindings of a workflow that pauses.

Call it in the transaction that pauses the workflow.

Source code in libraries/common/src/imbi/common/workflows/runtime.py
async def pause_bindings(
    conn: psycopg.AsyncConnection[typing.Any], workflow_id: uuid.UUID
) -> None:
    """Disable the trigger bindings of a workflow that pauses.

    Call it in the transaction that pauses the workflow.
    """
    await conn.execute(
        'UPDATE workflows.trigger_bindings SET enabled = FALSE,'
        " sync_state = 'pending', updated_at = NOW()"
        ' WHERE workflow_id = %s',
        (workflow_id,),
    )
    await conn.execute(f'NOTIFY {BINDINGS_CHANNEL}')