ask-graph
Durable workflow graphs for the ask-rb ecosystem. Define workflows as graphs of named steps with conditional routing, parallel execution, human-in-the-loop approval, per-item checkpointing loops, and sub-graph composition. Each step is a plain Ruby class.
gem "ask-graph"
## Quick Start
```ruby
require "ask-graph"
# Define a workflow
module ProcessOrder
class Workflow < Ask::Graph
step ValidateOrder
step ChargeCustomer, if: :valid?
step SendConfirmation, if: :valid?
step NotifyAdmin, unless: :valid?
private
def valid?
context.order.valid?
end
end
end
# Run it
result = ProcessOrder::Workflow.call(order: order)
```
Steps
Steps are plain Ruby classes with a call(context) method:
class ValidateOrder
def call(context)
context.order = OrderValidator.validate(context.order)
end
end
### Sequential steps
```ruby
module HandleCall
class Workflow < Ask::Graph
step Transcribe
step Classify
step Respond
end
end
```
### Conditional steps
```ruby
module HandleCall
class Workflow < Ask::Graph
step BookAppointment, if: :booking?
step EmergencyAlert, if: :emergency?
step HandleInquiry, unless: :known_intent?
private
def booking? = context.intent == "booking"
def emergency? = context.intent == "emergency"
def known_intent? = %w[booking emergency inquiry].include?(context.intent)
end
end
```
### Parallel steps
Use `steps` (plural) to run multiple steps simultaneously:
```ruby
module SyncData
class Workflow < Ask::Graph
step FetchRecords
# All three run in parallel
steps CrmUpdate, CalendarSync, SendNotification
step ConfirmResponse
end
end
```
### Human-in-the-loop (approve)
`approve` runs a step, then pauses the workflow and waits for external input:
```ruby
module ProcessBooking
class Workflow < Ask::Graph
step BookAppointment
approve ReviewBooking, if: :expensive?
step ConfirmBooking
end
end
```
After `approve` pauses, resume with:
```ruby
graph = ProcessBooking::Workflow.new
result = graph.run # runs, pauses after ReviewBooking
result = graph.resume(input: "approved") # resumes, runs ConfirmBooking
```
### Per-item loops
Use `context.each` inside a step for iteration with automatic checkpointing:
```ruby
class SendVoiceReminders
def call(context)
context.each(context.appointments) do |appt|
PhoneService.call(appt.number, appt.message)
end
end
end
module DailyReminders
class Workflow < Ask::Graph
step FetchAppointments
step SendVoiceReminders # loops with per-item checkpointing
step MarkComplete
end
end
```
If the process crashes mid-loop, it resumes from the last completed item.
Context
Shared state flows between steps via context:
class SetValue
def call(context)
context.greeting = "hello" # set via method
context[:color] = "blue" # set via bracket
end
end
class ReadValue
def call(context)
puts context.greeting # => "hello"
puts context[:color] # => "blue"
end
end
Sub-workflows
A step can delegate to another workflow by calling Workflow.call(context).
The context is automatically exported, passed to the sub-workflow, and
results are merged back on completion:
module OrderFulfillment
class NotifyCustomer
def call(context)
NotifyCustomer::Workflow.call(context)
end
end
class Workflow < Ask::Graph
step ValidatePayment
step NotifyCustomer
step ShipOrder
end
end
Or use context.run for the same thing:
class NotifyCustomer
def call(context)
context.run(NotifyCustomer::Workflow)
end
end
Checkpointing
Pass a storage backend to make workflows durable across crashes:
store = Ask::State::Memory.new # or Redis, SQLite, etc.
# Runs through all steps, saving after each
result = OrderFulfillment::Workflow.call(input, storage: store)
# If a crash occurs, resume from the last completed step
result = OrderFulfillment::Workflow.call(input, storage: store)
Error handling
Failed steps raise Ask::Graph::StepFailed with the step name:
module MyWorkflow
class Workflow < Ask::Graph
step RiskyOperation
end
end
begin
MyWorkflow::Workflow.call
rescue Ask::Graph::StepFailed => e
puts e. # => "RiskyOperation failed: connection timeout"
end
Development
bundle exec rake test
License
MIT