Operations
Build your business logic operations in an easy to understand format.
Most times when I'm adding a feature to a complex application, I tend to end up drawing a flowchart.
"We start here, then we check that option and if it's true then we do this, if it's false then we do that"
In effect, that flowchart is a state machine - with "decision states" and "action states". And Operations is intended to be a way of designing your ruby class so that flowchart becomes easy to follow.
Breaking Change
Version 0.7.0 includes breaking changes.  When you run bin/rails operations:migrations:install one of the migrations will drop your existing operations_tasks and operations_task_participants tables.  If you need the historic data in those tables, then edit the migration to rename the tables instead.  Also you will need to update your tests to use the new test method.
Usage
Drawing up a plan
Here's a simple example for planning a party.
class PlanAParty < Operations::Task
  has_attribute :date
  validates :date, presence: true 
  has_models :friends 
  has_model :food_shop 
  has_model :beer_shop
  has_models :available_friends
  starts_with :what_day_is_it?
  decision :what_day_is_it? do 
    condition { date.wday == 6 }
    go_to :buy_food
    condition { date.wday == 0 }
    go_to :relax
    condition { date.wday.in? [1, 2, 3, 4, 5]}
    go_to :go_to_work
  end 
  action :buy_food do 
    food_shop.order_party_food
  end
  go_to :buy_beer
  action :buy_beer do 
    beer_shop.order_drinks
  end
  go_to :invite_friends 
  action :invite_friends do 
    self.available_friends = friends.select { |friend| friend.available_on? date }
  end
  go_to :party!
  result :party!
  result :relax
  result :go_to_work
endThis task expects a date, a list of friends and a place to buy food and beer and consists of seven states - what_day_is_it?, buy_food, buy_beer, invite_fiends, party!, relax and go_to_work.
We would start the task as follows:
task = PlanAParty.call date: Date.today, friends: @friends, food_shop: @food_shop, beer_shop: @beer_shop
expect(task).to be_completed
# If it's a weekday
expect(task).to be_in "go_to_work"
# If it's Sunday
expect(task).to be_in "relax"
# If it's Saturday
expect(task).to be_in "party!"
expect(task.available_friends).to_not be_emptyWe define the attributes that the task contains and its starting state.
Attributes are stored in a JSON field within the table - either as simple data types (has_attribute :count, :integer, default: 0) or as a reference to another ActiveRecord model (has_model :user or has_model :submitted_by, "User" if you need to restrict the model to a particular class).  This uses the HasAttributes gem.
The initial state is what_day_is_it? which is a decision that checks the date supplied and moves to a different state based upon the conditions defined.  buy_food, buy_drinks and invite_friends are actions which do things.  Whereas party!, relax and go_to_work are results which end the task.
When you call the task, it runs through the process immediately and either fails with an exception or completes immediately.  You can test completed? or failed? and check the current_state.
If you prefer, call is alised as perform_now.
States
States are the heart of each task.  Each state defines a handler which does something, then moves to another state.
You can test the current state of a task via its current_state attribute, or by the helper method in? "some_state".
Action Handlers
An action handler does some work, and then transitions to another state. Once the action is completed, the task moves to the next state, which is specified using the go_to method or with a then declaration.
action :have_a_party do
  self.food = buy_some_food_for(number_of_guests)
  self.beer = buy_some_beer_for(number_of_guests)
  self.music = plan_a_party_playlist
end
go_to :send_invitationsThis is the same as:
action :have_a_party do
  self.food = buy_some_food_for(number_of_guests)
  self.beer = buy_some_beer_for(number_of_guests)
  self.music = plan_a_party_playlist
end.then :send_invitationsDecision Handlers
A decision handler evaluates a condition, then changes state depending upon the result.
The simplest tests a boolean condition.
decision :is_it_the_weekend? do 
  condition { Date.today.wday.in? [0, 6] }
  if_true :have_a_party 
  if_false :go_to_work
endAlternatively, you can evaluate multiple conditions in your decision handler.
decision :is_the_weather_good? do 
  condition { weather_forecast.sunny? }
  go_to :the_beach 
  condition { weather_forecast.rainy? }
  go_to :grab_an_umbrella 
  condition { weather_forecast.snowing? }
  go_to :build_a_snowman 
endIf no conditions are matched then the task fails with a NoDecision exception.
As a convention, use a question to name your decision handlers.
Result Handlers
A result handler marks the end of an operation. It's pretty simple.
result :doneAfter this result handler has executed, the task will then be marked as completed? and the task's current_state will be "done".
Waiting and interactions
Many processes involve waiting for some external event to take place.
A great example is user registration. The administrator sends an invitation email, the recipient clicks the link, enters their details, and once completed, the user record is created. This can be modelled as follows:
class UserRegistrationExample < Operations::Task
  has_attribute :email, :string
  validates :email, presence: true
  has_attribute :name, :string
  has_model :user, "User"
  delay 1.hour
  timeout 7.days
  starts_with :send_invitation
  action :send_invitation do
    UserMailer.with(email: email).invitation.deliver_later
  end
  go_to :name_provided?
  wait_until :name_provided? do
    condition { name.present? }
    go_to :create_user
  end
  interaction :register! do |name|
    self.name = name
  end.when :name_provided?
  action :create_user do
    self.user = User.create! name: name
  end
  go_to :done
  result :done
endWait handlers
The registration process performs an action, send_invitation and then waits until a name_provided?.  A wait handler is similar to a decision handler but if the conditions are not met, instead of raising an error, the task goes to sleep.  A background process (see below) wakes the task periodically to reevaluate the condition.  Or, an interaction can be triggered; this is similar to an action because it does something, but it also immediately reevaluates the current wait handler.  So in this case, when the register! interaction completes, the name_provided? wait handler is reevaluated and, because the name has now been supplied, it can move on to the create_user state.
When a task reaches a wait handler, it goes to sleep and expects to be woken up at some point in the future.  You can specify how often it is woken up by adding a delay 10.minutes declaration to your class.  The default is 1.minute.  Likewise, if a task does not change state after a certain period it fails with an Operations::Timeout exception.  You can set this timeout by declaring timeout 48.hours (the default is 24.hours).
Like decisions, use a question as the name for your wait handlers.
Interactions
Interactions are defined with the interaction declaration and they always wake the task.  The handler adds a new method to the task object - so in this case you would call @user_registration.register! "Alice" - this would wake the task, call the register! interaction handler, which in turn sets the name to Alice.  The wait handler would then be evaluated and the "create_user" and "done" states would be executed.  Also note that the register! interaction can only be called when the state is name_provided?.  This means that, if Alice registers, then someone hacks her email and uses the same invitation again, when the register! method is called, it will fail with an Operations::InvalidState exception - because Alice has already registered, the current state is "done" meaning this interaction cannot be called.
As a convention, use an exclamation mark to name your interaction handlers.
Background processor
In order for wait handlers and interactions to work, you need to wake up the sleeping tasks by calling Operations::Task.wake_sleeping.  You can add this to a rake task that is triggered by a cron job, or if you use SolidQueue you can add it to your recurring.yml.  Alternatively, you can run Operations::Task::Runner.start - this is a long running process that wakes sleeping tasks every 30 seconds (and deletes old tasks).
Starting tasks in the background
When a task is started, it runs in the current thread - so if you start the task within a controller, it will run in the context of your web request. When it reaches a wait handler, the execution stops and control returns to the caller. The background processor then uses ActiveJob to wake the task at regular intervals, evaluating the wait handler and either progressing if a condition is met or going back to sleep if the conditions are not met. Because tasks go to sleep and the job that is processing it then ends, you should be able to create hundreds of tasks at any one time without starving your application of ActiveJob workers (although there may be delays when processing if your queues are full).
If you want the task to be run completely in the background (so it sleeps immediately and then starts when the background processor wakes it), you can call MyTask.later(...) (which is also aliased as perform_later).
Example wait and interaction handlers
Because background tasks are woken using ActiveJob, you may wish to control exactly how these jobs are handled.
You can specify which ActiveJob queue they are placed on (with the default value being :default) - this is the equivalent of setting queue_as :my_queue in ActiveJob.  And you can even specify which queue adapter they use (if, for example, you want to use SolidQueue for most of your background jobs, but Sidekiq for a certain subset of tasks).
class MyBackgroundOperation < Operations::Task 
  queue :low_priority
  runs_on :sidekiq
  # ...
endSub tasks
If your task needs to start sub-tasks, it can use the start method, passing the sub-task class and arguments.
action :start_sub_tasks do 
  3.times { |i| start OtherThingTask, number: i }
endSub-tasks are always started in the background so they do not block the progress of their parent task.  You can then track those sub-tasks using the sub_tasks, active_sub_tasks, completed_sub_tasks and failed_sub_tasks associations in a wait handler.
wait_until :sub_tasks_have_completed? do 
  condition { sub_tasks.all? { |st| st.completed? } }
  go_to :all_sub_tasks_completed 
  condition { sub_tasks.any? { |st| st.failed? } }
  go_to :some_sub_tasks_failed
endIndexing data and results
If your task references other ActiveRecord models, you may need to find which tasks your models were involved in.  For example, if you want to see which tasks a particular user initiated.  You can declare an index on any has_model or has_models definitions and the task will automatically create a polymorphic join table that can be searched.  You can then include Operations::Participant into your model to find which tasks it was involved in (and which attribute it was stored under).
For example:
class IndexesModelsTask < Operations::Task
  has_model :user, "User"
  validates :user, presence: true
  has_models :documents, "Document"
  has_attribute :count, :integer, default: 0
  index :user, :documents
  ... 
end
@task = IndexesModelsTask.call user: @user, documents: [@document1, @document2]
@user.operations.include?(@task) # => true 
@user.operations_as(:user).include?(@task) # => true 
@user.operations_as(:documents).include?(@task) # => false - the user is stored in the user attribute, not the documents attributeFailures and exceptions
If any handlers raise an exception, the task will be terminated. It will be marked as failed? and the details of the exception will be stored in exception_class, exception_message and exception_backtrace.
Task life-cycle and the database
There is an ActiveRecord migration that creates the operations_tasks table.  Use bin/rails operations:install:migrations to copy it to your application, then run bin/rails db:migrate to add the table to your application's database.
When you call a task, it is written to the database.  Then whenever a state transition occurs, the task record is updated.
This gives you a number of possibilities:
- you can access the data (or error state) of a task after it has completed
- you can use TurboStream broadcasts to update your user-interface as the state changes
- tasks can wait until an external event of some kind
- the tasks table acts as an audit trail or activity log for your application
However, it also means that your database table could fill up with junk that you're no longer interested in.  Therefore you can specify the maximum age of a task and, periodically, clean old tasks away.  Every task has a delete_at field that, by default, is set to 90.days.from_now.  This can be changed by declaring delete_after 7.days - which will then mark the delete_at field for instances of that particular class to seven days.  To actually delete those records you should set a cron job or recurring task that calls Operations::Task.delete_old.  If you use the Operations::Task::Runner, it does this automatically.
Testing
Because the flow for a task may be complex, it's best to test each state in isolation.  To help with this, there is a test method on the Operations::Task class, which creates a task, in your desired state, then runs the appropriate handler.  Then you can check that it has done what you expect.
class WeekendChecker < Operations::Task
  has_attribute :day_of_week, :string, default: "Monday"
  validates :day_of_week, presence: true
  starts_with :is_it_the_weekend?
  decision :is_it_the_weekend? do
    condition { %w[Saturday Sunday].include? day_of_week }
    if_true :weekend
    if_false :weekday
  end
  result :weekend
  result :weekday
end
task = WeekendChecker.test :is_it_the_weekend?, day_of_week: "Saturday"
expect(task).to be_in :weekend
task = WeekendChecker.test :is_it_the_weekend?, day_of_week: "Wednesday"
expect(task).to be_in :weekdayInstallation
Step 1: Add the gem to your Rails application's Gemfile:
gem "standard_procedure_operations"Step 2: Run bundle install, then copy and run the migrations to add the tasks table to your database:
bin/rails operations:install:migrations 
bin/rails db:migrateStep 3: Create your own operations by inheriting from Operations::Task and revel in the stateful flowcharts!
class DailyLife < Operations::Task
  starts_with :am_i_awake?
  decision :am_i_awake? do 
    condition { (7..23).include?(Time.now.hour) }
    if_true :live_like_theres_no_tomorrow 
    if_false :rest_and_recuperate
  end 
  result :live_like_theres_no_tomorrow 
  result :rest_and_recuperate
endStep 4: If you're using RSpec for testing, add `require "operations/matchers" to your "spec/rails_helper.rb" file.
License
The gem is available as open source under the terms of the LGPL License. This may or may not make it suitable for your needs.
Roadmap
- Specify inputs (required and optional) per-state, not just at the start
- Always raise errors instead of just recording a failure (will be useful when dealing with sub-tasks)
-  Deal with actions that have forgotten to call go_toby enforcing static state transitions withgo_to
- Simplify calling sub-tasks (and testing them)
- Figure out how to stub calling sub-tasks with known results data
- Figure out how to test the parameters passed to sub-tasks when they are called
- Make Operations::Task work in the background using ActiveJob
- Add pause/resume capabilities (for example, when a task needs to wait for user input)
- Add wait for sub-tasks capabilities
- Add visualization export for task flows
- Replace ActiveJob with a background process
- Rename StateManagent with Plan
- Add interactions
- Specify ActiveJob queues and adapters