DEV Community

l3o6
l3o6

Posted on

Using Temporal SDK in Haskell

What Is Temporal

Temporal is a server that enables reliable execution and management of distributed, multi-step processes that unfold over time.
For example:

  • a user has paid for an order;
  • inventory must be reserved;
  • if the warehouse service is temporarily unavailable, the request must be retried;
  • if the item is unavailable, a replacement must be offered;
  • if payment has not yet arrived, wait for a signal. A conventional program can lose process state when it crashes, the server restarts, or a network failure occurs. Temporal persists the state and resumes execution from the appropriate point. It provides a CLI and web UI for managing, monitoring, and visualizing processes. It is an open-source alternative to systems such as AWS Step Functions, Azure Durable Functions, and others.

Installing and Running Temporal Locally

For example, using Nix, Docker, or Brew.

$ brew install temporal
$ temporal server start-dev
Temporal CLI 1.8.3 (Server 1.31.2, UI 2.50.1)

Temporal Server:      localhost:7233
Temporal Persistence: in-memory
Temporal UI:          http://localhost:8233
Temporal Metrics:     http://localhost:54946/metrics
Enter fullscreen mode Exit fullscreen mode

Temporal SDK and langchain-hs

The example is implemented with the unofficial Temporal Haskell SDK. The project is described as being under active development, but it already supports implementing basic scenarios. The API may change in the future.
All Temporal SDKs, including the Haskell SDK, use a bridge written in Rust under the hood.

https://github.com/mercury/hs-temporal-sdk

The langchain-hs library is used to demonstrate a fulfillment agent. This is also a young project under active development.

https://github.com/tusharad/langchain-hs

Both packages are cloned locally and connected through Cabal to access the latest changes.

Core Temporal Components and Their Representation in the Haskell SDK

The full example code is available at https://github.com/lbobylev/hs-temporal-sdk-demo

Workflow

The Workflow is central: it describes the business logic of a process. It can consist of several Activities that run in a defined sequence and can be distributed over time.
In this case, it is a fragment of a hypothetical purchase-payment process.
It is deliberately simplified. The Workflow waits for an external CLI signal indicating that payment has been received, then reserves the product and offers the user a fulfillment option.

waitForPayment :: (RequireCallStack) => Int -> Workflow FulfillmentProposal
waitForPayment orderNumber = do
    -- Create a state variable to store the payment amount
    paymentAmount <- newStateVar Nothing

    -- Set up a signal handler that is called when the payment-confirmation signal is received
    setSignalHandler paymentConfirmed $ \amount ->
        writeStateVar paymentAmount (Just amount)

    -- Wait until the state variable is updated by the signal
    waitCondition $ do
        received <- readStateVar paymentAmount
        return $ received /= Nothing

    amount <- readStateVar paymentAmount
    case amount of
        Nothing -> error "payment amount is missing"
        Just _ -> do
            -- Helper function for extending activity start parameters
            -- Set a timeout for a single attempt
            let opts = defaultStartActivityOptions . StartToClose . seconds

            -- After receiving the payment-confirmation signal, attempt to reserve the product
            reservationResult <- executeActivity reserveProductActivity (opts 5) orderNumber

            -- Depending on the reservation result, offer the user a fulfillment option
            fulfillmentProposal <- executeActivity fulfillmentAgentActivity (opts 60) reservationResult

            return fulfillmentProposal

-- Create a workflow provider
paymentWorkflow = provideWorkflow JSON "payment-workflow" waitForPayment
Enter fullscreen mode Exit fullscreen mode

Activity

An Activity is a separate unit of work that runs within a Workflow.
In this example, the first product-reservation attempt intentionally fails to demonstrate Activity retries according to the retry policy. StartToClose limits the duration of each individual attempt.
The final result also deliberately fails to trigger the fulfillment agent, which simulates offering the user an alternative fulfillment option.

reserveProduct :: Int -> Activity () ReservationResult
reserveProduct orderNumber = do
    -- Get the current attempt number
    info <- askActivityInfo
    let attempt = Activity.attempt info

    liftIO $
        putStrLn
            ( "Reserving product for order "
                <> show orderNumber
                <> ", attempt "
                <> show attempt
            )

    liftIO $
        if attempt == 1
            then ioError $ userError "temporary warehouse error"
            else putStrLn "Reservation completed"

    -- Return an unsuccessful reservation result
    return $ ReservationResult ReservationFailed (Just WarehouseUnavailable)

-- Create an activity provider
reserveProductActivity = provideActivity JSON "reserve-product" reserveProduct
Enter fullscreen mode Exit fullscreen mode

After the reservation error is received, the fulfillment agent is called and simulates offering the user an alternative fulfillment option.
In this case, it is a stub for a real tool that would return a list of fulfillment options. In a real application, this would be a call to an external service.
The agent is implemented using the langchain-hs ReAct agent.

fulfillmentOptionsTool :: (Monad m) => Tool m
fulfillmentOptionsTool =
    createTool
        "fulfillment_options"
        "Provides fulfillment options based on reservation result"
        ( object
            [ "type" .= ("object" :: Text)
            , "properties" .= object []
            ]
        )
        runFulfillmentOptionsTool

runFulfillmentOptionsTool :: (Monad m) => Value -> m (Either LangchainError Text)
runFulfillmentOptionsTool _ =
    return . Right . pack . show $
        [ ShipFromWarehouse "Warehouse B"
        , OfferSubstitution 1234
        , WaitForRestock 5
        ]

fulfillmentAgent :: ReservationResult -> Activity () FulfillmentProposal
fulfillmentAgent reservation = do
    -- Depending on the reservation result, either return a successful result or call the fulfillment agent to obtain alternative fulfillment options
    if status reservation == Reserved
        then return $ FulfillmentProposal Nothing [] "Product reserved successfully"
        else do

            -- Start the langchain-hs ReAct agent

            apiKey <- liftIO $ pack <$> getEnv "OPENAI_API_KEY"
            let openai = newOpenAI apiKey "gpt-4o-mini"
                agent = createReActAgent openai [fulfillmentOptionsTool]
                prompt =
                    [ systemMessage $
                        "You MUST call the fulfillment_options tool before answering. "
                            <> "Return ONLY valid JSON, with no Markdown, code fences, or explanation. "
                            <> "The response must be exactly one object with an \"opts\" field. "
                            <> "The \"opts\" field must be a JSON array. "
                            <> "Each option must use exactly one of these formats: "
                            <> "{\"tag\":\"ShipFromWarehouse\",\"contents\":\"Warehouse B\"}, "
                            <> "{\"tag\":\"OfferSubstitution\",\"contents\":1234}, "
                            <> "{\"tag\":\"WaitForRestock\",\"contents\":5}. "
                            <> "If there are no options, return {\"opts\":[]}."
                    , userMessage "The product reservation failed. Please provide fulfillment options."
                    ]
            result <- runExceptT $ runReActAgent agent prompt

            case result of
                Left err -> error $ "Error running fulfillment agent: " <> show err
                Right message -> do

                    -- Attempt to decode the agent response into the expected format

                    let decode = eitherDecodeStrict' . encodeUtf8 . extractMessageText
                        content = decode message :: Either String FulfillmentAgentResponse
                    case content of
                        Left e -> error $ "Error decoding fulfillment options: " <> e
                        Right response ->
                            return $
                                FulfillmentProposal
                                    (reason reservation)
                                    (opts response)
                                    "Product reservation failed"

-- Create an activity provider
fulfillmentAgentActivity = provideActivity JSON "fulfillment-agent" fulfillmentAgent
Enter fullscreen mode Exit fullscreen mode

Starting a Workflow and Sending a Signal

For a more detailed explanation of how Temporal works, see the official documentation.
Define a task queue from which the worker will receive tasks from the Temporal server.
Next, create the payment-received signal.
Disable telemetry and logging.

-- Create a queue
taskQueue :: TaskQueue
taskQueue = TaskQueue "temporal-demo"

paymentConfirmed :: KnownSignal '[Int]
paymentConfirmed = KnownSignal "payment-confirmed" JSON

main :: IO ()
main = do
    bracketRuntime NoTelemetry $ \runtime ->
        runNoLoggingT $
            bracketClient runtime defaultClientConfig $ \client -> do

                let workerConfig = configure () (paymentWorkflow, reserveProductActivity, fulfillmentAgentActivity) $ do
                        setNamespace (Namespace "default")
                        setTaskQueue taskQueue

                worker <- startWorker client workerConfig

                waitWorker worker
Enter fullscreen mode Exit fullscreen mode

After starting main in GHCi and running the start command in the terminal, a new workflow should appear in the Temporal UI in a state of waiting for a signal.

$ temporal workflow start \
  --type payment-workflow \     # workflow name
  --task-queue temporal-demo \  # queue name
  --workflow-id order-3 \       # execution identifier
  --input 1003                  # hypothetical order number
Running execution:
  WorkflowId  order-3
  RunId       01a091e9-a48f-7e6b-a6c4-dac2f476b5b2
  Type        payment-workflow
  Namespace   default
  TaskQueue   temporal-demo
Enter fullscreen mode Exit fullscreen mode

Send the payment-confirmation signal:

$ temporal workflow signal \
  --workflow-id order-3 \       # workflow identifier
  --signal payment-confirmed \  # signal name
  --input 1000                  # payment amount
Signal workflow succeeded
Enter fullscreen mode Exit fullscreen mode

The Haskell Temporal SDK is still under active development, but it is already usable for basic workflows with signals, activities, retries, and workers.

Top comments (0)