Workflows & Actions

Put work in actions and coordinate it in workflows. This split lets Waymark save progress between calls and retry an individual action when you ask it to.

Two primitives

Actions are the distributed work. Network calls, database queries, file I/O, anything that can fail and that you'd want to retry independently. An action is a single async def decorated with @action. Each invocation is recorded in Postgres, dispatched to a Python worker, runs to completion or failure, and the result is persisted before the workflow advances.

Workflows are the durable control flow. Loops, conditionals, parallel branches, try/except. They orchestrate actions but don't do heavy lifting themselves. A workflow is a class that subclasses Workflow and is decorated with @workflow. The class body has one method that matters, run(), and that body is what Waymark compiles into a durable program.

The quickstart's math example uses three actions. Their implementations are omitted here so you can see how the workflow connects them:

import asyncio
from waymark import Workflow, action, workflow

@action
async def compute_factorial(n: int) -> int:
    ...

@action
async def compute_fibonacci(n: int) -> int:
    ...

@action
async def summarize(*, n: int, factorial_value: int, fibonacci_value: int) -> str:
    ...

@workflow
class ParallelMathWorkflow(Workflow):
    async def run(self, number: int) -> str:
        factorial_value, fibonacci_value = await asyncio.gather(
            compute_factorial(number),
            compute_fibonacci(number),
            return_exceptions=False,
        )
        return await summarize(
            n=number,
            factorial_value=factorial_value,
            fibonacci_value=fibonacci_value,
        )

The runtime saves the workflow's progress between action calls. Workers execute the three actions. gather propagates failures; return_exceptions=False is optional. Collecting exceptions with return_exceptions=True is unsupported and rejected when the workflow is compiled.

Running one looks like calling any other async function:

result = await ParallelMathWorkflow().run(7)

The call queues the workflow into Postgres, the runtime executes it durably, and you get the result back. If the workflow ends with an exception it didn't catch, the call raises WorkflowFailedError, whose exception_value describes that exception. The Quickstart walks through this end to end, and the Workflow API covers the call's other options.

Actions

Actions are written like any other async Python function:

@action
async def fetch_user(user_id: str) -> User:
    return await db.fetch_one(User, user_id)

A few things to know:

  • Inside a workflow, call an action where you await it - await fetch_user(user_id), or directly inside asyncio.gather(...). Creating the call first and awaiting it later (task = fetch_user(user_id)) doesn't compile.
  • An action that raises an exception is, by default, marked failed and not retried. You opt into retries by wrapping the call in self.run_action(...) and providing a RetryPolicy. See Retries & Timeouts.
  • Arguments and results travel between processes and are persisted in Postgres, so they need to be values Waymark can serialize: primitives, containers, pydantic models, dataclasses and common stdlib types. See Introduction to Python for the full list, and Known issues for the values that currently change shape on the way.
  • Actions can use anything Python lets them: random(), datetime.now(), HTTP clients, file handles. Anything outside what the workflow compiler accepts belongs in an action.

Workflows

A workflow has one job: orchestrate actions through control flow. The constraints on the body of run() come from what the compiler can lower to IR - loops, conditionals, parallel gather, try/except, durable sleep, returns. Anything outside that vocabulary is rejected the first time the workflow is run or scheduled.

The runtime persists state as the workflow runs. When another node resumes it, execution continues from the last saved state without replaying run() from the beginning. Side effects and I/O belong in actions, outside the compiled control flow. An action that was in flight during a node failure may run again; see Production Deployment.

Workflows can run on a cron or a fixed interval with schedule_workflow, and can pause indefinitely with await asyncio.sleep(...) - a durable sleep that survives node restarts.

Dependency injection

Actions can declare dependencies the same way Mountaineer and FastAPI controllers do:

from typing import Annotated
from waymark import Depends, action

@action
async def send_email(
    to: str,
    subject: str,
    emailer: Annotated[EmailClient, Depends(get_email_client)],
) -> None:
    await emailer.send(to=to, subject=subject)

The Depends helper is re-exported from mountaineer-di; the older Depend(...) spelling is kept as an alias. Dependencies are resolved per action invocation by the worker, so any per-request lifecycle (database sessions, scoped clients) is handled the same way you'd handle it in a web handler.

Workflow bodies don't take injected dependencies - they never run in a Python worker. The body is compiled, and the node's runtime executes the compiled program. If a workflow needs a database handle, it asks for it via an action.

How the line gets enforced

Waymark's compiler walks the AST of run() and refuses anything it doesn't recognize. Common things you might reach for that don't work inside a workflow body:

  • f-strings, anywhere in the body (build them inside an action instead).
  • Calling most functions other than actions and the workflow's own methods; see what does not compile for the exact list.
  • Constructing classes other than simple pydantic models and dataclasses (models without validators or an __init__ of their own, dataclasses without __post_init__, defined in or imported by name into the workflow's module).

The error you get back tells you what wasn't recognized, and one from the SDK's builder also says what to do instead. The Control Flow guide walks through the patterns the compiler supports today, with examples for each, and lists the known issues that still slip past it.