standard_procedure_operations 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +128 -200
  3. data/app/jobs/operations/delete_old_task_job.rb +5 -0
  4. data/app/jobs/operations/wake_task_job.rb +5 -0
  5. data/app/models/concerns/operations/participant.rb +4 -9
  6. data/app/models/operations/task/index.rb +22 -0
  7. data/app/models/operations/task/plan/action_handler.rb +7 -6
  8. data/app/models/operations/task/plan/decision_handler.rb +9 -20
  9. data/app/models/operations/task/plan/interaction_handler.rb +20 -0
  10. data/app/models/operations/task/plan/result_handler.rb +2 -9
  11. data/app/models/operations/{agent → task/plan}/wait_handler.rb +6 -6
  12. data/app/models/operations/task/plan.rb +44 -11
  13. data/app/models/operations/{agent → task}/runner.rb +4 -7
  14. data/app/models/operations/task.rb +48 -82
  15. data/app/models/operations/task_participant.rb +2 -7
  16. data/db/migrate/20250701190516_rename_existing_operations_tables.rb +19 -0
  17. data/db/migrate/20250701190716_create_new_operations_tasks.rb +20 -0
  18. data/db/migrate/20250702113801_create_task_participants.rb +10 -0
  19. data/lib/operations/version.rb +1 -1
  20. data/lib/operations.rb +1 -3
  21. metadata +12 -24
  22. data/app/jobs/operations/agent/find_timeouts_job.rb +0 -5
  23. data/app/jobs/operations/agent/runner_job.rb +0 -5
  24. data/app/jobs/operations/agent/timeout_job.rb +0 -5
  25. data/app/jobs/operations/agent/wake_agents_job.rb +0 -5
  26. data/app/models/operations/agent/interaction_handler.rb +0 -30
  27. data/app/models/operations/agent/plan.rb +0 -38
  28. data/app/models/operations/agent.rb +0 -31
  29. data/app/models/operations/task/data_carrier.rb +0 -16
  30. data/app/models/operations/task/deletion.rb +0 -17
  31. data/app/models/operations/task/exports.rb +0 -45
  32. data/app/models/operations/task/input_validation.rb +0 -17
  33. data/app/models/operations/task/testing.rb +0 -63
  34. data/db/migrate/20250127160616_create_operations_tasks.rb +0 -17
  35. data/db/migrate/20250309160616_create_operations_task_participants.rb +0 -15
  36. data/db/migrate/20250404085321_add_becomes_zombie_at_field.operations.rb +0 -7
  37. data/db/migrate/20250407143513_agent_fields.rb +0 -9
  38. data/db/migrate/20250408124423_add_task_participant_indexes.rb +0 -5
  39. data/lib/operations/exporters/svg.rb +0 -399
  40. data/lib/operations/has_data_attributes.rb +0 -50
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ae45b3244510bc8b6be2c06051cc8ac01907e5733de968e4f13c410e7a53cb9
4
- data.tar.gz: e23c121129ba54551c0a240e5bd2ac19a6116ee4d70605df2b8b8ffae34e0f70
3
+ metadata.gz: d5804e91c48ca11b9306c1d8d2c3006df7916dcd3e768629cf492b1daa409725
4
+ data.tar.gz: 2c0f4a513bb1bd685f44daf46f28e7ecb2ac0909a703ae79240ff0587fc74d1c
5
5
  SHA512:
6
- metadata.gz: 31faffeea577216f77828ef9fcbb004a82943ba362e0cb82fc07ee9059d11078a33fda73439a4c751f3774fa589a6004e5a0ab68007c1e07e951cd3c2dc0f1f6
7
- data.tar.gz: 7f4547335624db494e2d425e5724a8dfdbe73a1ab0c29d2fdf402db69d17c2ccee2a069051d27c6ae6d0ee28abfe2d701ea218337263f07d32aeaa9f8ce5e889
6
+ metadata.gz: 1f8335c58e7df31046d08ab67cf99d7bf1cd0a1a52aa24433b79932f418c9d0de945d702d7039eabb6e66ed4a94484a2aa3d9bdb415d60859b9007feff9c64e2
7
+ data.tar.gz: 8cb8aa2940d5b4658582c14aef3db7d715c3c6af2f66b1bd838514ad11e67a6edbc8a9b95eb9cd8e57fc8293156365acaf95e017d8d13572fe5d1259c2b27269
data/README.md CHANGED
@@ -7,6 +7,10 @@ Most times when I'm adding a feature to a complex application, I tend to end up
7
7
 
8
8
  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.
9
9
 
10
+ ## Breaking Change
11
+
12
+ Version 0.7.0 includes breaking changes. There are migrations which rename your existing `operations_tasks` and `operations_task_participants` tables (so the data is not deleted), then a new `operations_tasks` table is created with a simplified structure.
13
+
10
14
  ## Usage
11
15
 
12
16
  ### Drawing up a plan
@@ -15,7 +19,12 @@ Here's a simple example for planning a party.
15
19
 
16
20
  ```ruby
17
21
  class PlanAParty < Operations::Task
18
- input :date, :friends, :food_shop, :beer_shop
22
+ has_attribute :date
23
+ validates :date, presence: true
24
+ has_models :friends
25
+ has_model :food_shop
26
+ has_model :beer_shop
27
+ has_models :available_friends
19
28
  starts_with :what_day_is_it?
20
29
 
21
30
  decision :what_day_is_it? do
@@ -42,9 +51,7 @@ class PlanAParty < Operations::Task
42
51
  end
43
52
  go_to :party!
44
53
 
45
- result :party! do |results|
46
- results.available_friends = available_friends
47
- end
54
+ result :party!
48
55
  result :relax
49
56
  result :go_to_work
50
57
  end
@@ -59,26 +66,49 @@ task = PlanAParty.call date: Date.today, friends: @friends, food_shop: @food_sho
59
66
 
60
67
  expect(task).to be_completed
61
68
  # If it's a weekday
62
- expect(task.is?(:go_to_work)).to be true
69
+ expect(task).to be_in "go_to_work"
63
70
  # If it's Sunday
64
- expect(task.is?(:relaz)).to be true
71
+ expect(task).to be_in "relax"
65
72
  # If it's Saturday
66
- expect(task.is?(:party!)).to be true
67
- expect(task.results[:available_friends]).to_not be_empty
73
+ expect(task).to be_in "party!"
74
+ expect(task.available_friends).to_not be_empty
68
75
  ```
69
- We define the `inputs` that the task expects and its starting `state`.
76
+ We define the `attributes` that the task contains and its starting `state`.
70
77
 
71
- 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. `relax` and `go_to_works` are _results_ which end the task. Whereas `buy_food`, `buy_drinks` and `invite_friends` are _actions_ which do things. And the `party!` _result_ also returns some data - a list of `available_friends`.
78
+ 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.
72
79
 
73
- When you `call` the task, it runs through the process immediately and either fails with an exception or completes immediately.
80
+ 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`.
74
81
 
75
- You can also plan tasks that continue working over a period of time. These [Agents](/docs/agents.md) have extra capabilities - `wait_until` and `interaction`s - but require a bit of setup, so we'll come back to them later.
82
+ If you prefer, `call` is alised as `perform_now`.
76
83
 
77
84
  ### States
78
85
 
79
86
  `States` are the heart of each task. Each `state` defines a `handler` which does something, then moves to another `state`.
80
87
 
81
- Any state can also declare which data it expects - both required `inputs`, as well as `optional` inputs. If the task enters a `state` and the required data is not present then it fails with an `ArgumentError`. Optional input declarations do not actually do anything but are useful for documenting your task.
88
+ You can test the current state of a task via its `current_state` attribute, or by the helper method `in? "some_state"`.
89
+
90
+ ### Action Handlers
91
+
92
+ 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.
93
+
94
+ ```ruby
95
+ action :have_a_party do
96
+ self.food = task.buy_some_food_for(number_of_guests)
97
+ self.beer = task.buy_some_beer_for(number_of_guests)
98
+ self.music = task.plan_a_party_playlist
99
+ end
100
+ go_to :send_invitations
101
+ ```
102
+ This is the same as:
103
+ ```ruby
104
+ action :have_a_party do
105
+ self.food = task.buy_some_food_for(number_of_guests)
106
+ self.beer = task.buy_some_beer_for(number_of_guests)
107
+ self.music = task.plan_a_party_playlist
108
+ end.then :send_invitations
109
+ ```
110
+
111
+ [Example action handler](/spec/examples/single_action_spec.rb)
82
112
 
83
113
  ### Decision Handlers
84
114
 
@@ -94,17 +124,6 @@ decision :is_it_the_weekend? do
94
124
  end
95
125
  ```
96
126
 
97
- A decision can also mark a failure, which will terminate the task and raise an `Operations::Failure`.
98
- ```ruby
99
- decision :authorised? do
100
- condition { user.administrator? }
101
-
102
- if_true :do_some_work
103
- if_false { fail_with "Unauthorised" }
104
- end
105
- ```
106
- (In theory the block used in the `fail_with` case can do anything within the [DataCarrier context](#data-and-results) - so you could set internal state or call methods on the containing task - but I've not tried this yet).
107
-
108
127
  Alternatively, you can evaluate multiple conditions in your decision handler.
109
128
 
110
129
  ```ruby
@@ -120,176 +139,130 @@ end
120
139
 
121
140
  If no conditions are matched then the task fails with a `NoDecision` exception.
122
141
 
123
- You can specify the data that is required for a decision handler to run by specifying `inputs` and `optionals`:
124
- ```ruby
125
- decision :authorised? do
126
- inputs :user
127
- optional :override
128
- condition { override || user.administrator? }
142
+ As a convention, use a question to name your decision handlers.
129
143
 
130
- if_true :do_some_work
131
- if_false { fail_with "Unauthorised" }
132
- end
133
- ```
134
-
135
- ### Action Handlers
136
-
137
- An action handler does some work, and then transitions to another state. The state transition is defined statically after the action, using the `go_to` method.
138
-
139
- ```ruby
140
- action :have_a_party do
141
- self.food = task.buy_some_food_for(number_of_guests)
142
- self.beer = task.buy_some_beer_for(number_of_guests)
143
- self.music = task.plan_a_party_playlist
144
- end
145
- go_to :send_invitations
146
- ```
147
-
148
- You can also specify the required and optional data for your action handler using parameters or within the block.
149
-
150
- ```ruby
151
- action :have_a_party do
152
- inputs :number_of_guests
153
- optional :music
154
- self.food = task.buy_some_food_for(number_of_guests)
155
- self.beer = task.buy_some_beer_for(number_of_guests)
156
- self.music ||= task.plan_a_party_playlist
157
- end
158
- go_to :send_invitations
159
- ```
144
+ [Example decision handler](/spec/examples/conditional_action_spec.rb)
160
145
 
161
146
  ### Result Handlers
162
147
 
163
- A result handler marks the end of an operation, optionally returning some results. If you're using the results collection, you will need to copy your desired results from your [data](#data-and-results) to your results object. This is so only the information that matters to you is stored as the results.
148
+ A result handler marks the end of an operation. It's pretty simple.
164
149
 
165
150
  ```ruby
166
- action :send_invitations do
167
- self.invited_friends = (0..number_of_guests).collect do |i|
168
- friend = friends.pop
169
- FriendsMailer.with(recipient: friend).party_invitation.deliver_later unless friend.nil?
170
- friend
171
- end.compact
172
- end
173
- go_to :ready_to_party
174
-
175
- result :ready_to_party do |results|
176
- results.invited_friends = invited_friends
177
- end
151
+ result :done
178
152
  ```
179
- After this result handler has executed, the task will then be marked as `completed?`, the task's state will be `ready_to_party` and `results[:invited_friends]` will contain an array of the people you sent invitations to.
180
153
 
181
- If you don't have any meaningful results, you can omit the block on your result handler. I've found that I tend to do this instead, accessing any information I need via `data` and attributes.
182
- ```ruby
183
- result :go_to_work
184
- ```
185
- In this case, the task will be marked as `completed?`, the task's state will be `go_to_work` and `results` will be empty.
154
+ After this result handler has executed, the task will then be marked as `completed?` and the task's `current_state` will be "done".
155
+
156
+ ### Waiting and interactions
157
+
158
+ Many processes involve waiting for some external event to take place.
186
159
 
187
- You can also specify the required and optional data for your result handler within the block.
160
+ 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:
188
161
 
189
162
  ```ruby
190
- result :ready_to_party do |results|
191
- inputs :invited_friends
163
+ class UserRegistrationExample < Operations::Task
164
+ has_attribute :email, :string
165
+ validates :email, presence: true
166
+ has_attribute :name, :string
167
+ has_model :user, "User"
168
+ delay 1.hour
169
+ timeout 7.days
170
+ starts_with :send_invitation
171
+
172
+ action :send_invitation do
173
+ UserMailer.with(email: email).invitation.deliver_later
174
+ end
175
+ go_to :name_provided?
192
176
 
193
- results.invited_friends = invited_friends
194
- end
195
- ```
177
+ wait_until :name_provided? do
178
+ condition { name.present? }
179
+ go_to :create_user
180
+ end
196
181
 
197
- ### Calling an operation
182
+ interaction :register! do |name|
183
+ self.name = name
184
+ end.when :name_provided?
198
185
 
199
- Each task has a `call` method that takes your inputs and runs the task immediately. You can then test to see if it has `completed?` or `failed?` and check the final `state` and `results`
186
+ action :create_user do
187
+ self.user = User.create! name: name
188
+ end
189
+ go_to :done
200
190
 
201
- ```ruby
202
- begin
203
- task = PlanAParty.call date: Date.today, friends: @friends, food_shop: @food_shop, beer_shop: @beer_shop
204
-
205
- expect(task).to be_completed
206
- # If it's a weekday
207
- expect(task.is?(:go_to_work)).to be true
208
- # If it's Sunday
209
- expect(task.is?(:relax)).to be true
210
- # If it's Saturday
211
- expect(task.is?(:party!)).to be true
212
- expect(task.results[:available_friends]).to_not be_empty
213
- rescue => ex
214
- expect(task).to be_failed
215
- expect(task.results[:exception_message]).to eq ex.message
216
- expect(task.results[:exception_class]).to eq ex.class
191
+ result :done
217
192
  end
218
193
  ```
194
+ #### Wait handlers
219
195
 
220
- OK - so that's a pretty longwinded way of performing a simple task.
221
-
222
- But many operations end up as complex flows of conditionals and actions, often spread across multiple classes and objects. This means that someone trying to understand the rules for an operation can spend a lot of time tracing through code, understanding that flow.
196
+ 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 bwlow) 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.
223
197
 
224
- In [Collabor8Online](https://www.collabor8online.co.uk/), when a user wants to download a file, the task is complicated, based upon feature flags, configuration options and permissions. This involves over fifteen decisions, fifteen actions and, previously, the logic for this was scattered across a number of models and controllers, making it extremely difficult to see what was happening. Whereas now, all the logic for downloads is captured within one overall plan that calls out to three other sub-tasks and the logic is easy to follow.
198
+ 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`).
225
199
 
226
- ### Data and results
200
+ Like decisions, use a question as the name for your wait handlers.
227
201
 
228
- Each operation carries its own, mutable, [data](/app/models/operations/task/data_carrier.rb) for the duration of the operation.
202
+ #### Interactions
229
203
 
230
- This is provided when you `call` the operation to start it and is passed through to each decision, action and result. If you modify the data then that modification is passed on to the next handler.
204
+ 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.
231
205
 
232
- Within handlers you can read the data directly (the implementation uses `instance_eval`/`instance_exec`). Here the `build_name` action knows the `first_name` and `last_name` provided and adds in a new property of `name`.
206
+ As a convention, use an exclamation mark to name your interaction handlers.
233
207
 
234
- Accessing the task from outside of a handler (for example in your controller) you can either access the task's `data` property. Or if you have defined your `inputs` and `optional` fields, they will also be available as attributes on the task model itself.
208
+ #### Background processor
235
209
 
236
- ```ruby
237
- class CombineNames < Operations::Task
238
- inputs :first_name, :last_name
239
- optional :name
240
- starts_with :build_name
241
- validates :first_name, presence: true
242
- validates :last_name, presence: true
243
-
244
- action :build_name do
245
- self.name = "#{first_name} #{last_name}"
246
- go_to :done
247
- end
210
+ 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).
248
211
 
249
- result :done
250
- end
212
+ #### Starting tasks in the background
251
213
 
252
- task = CombineNames.call first_name: "Alice", last_name: "Aardvark"
253
- task.name # => Alice Aardvark - `name` is defined as `optional`, so it is available directly on the task
254
- task.data[:name] # => Alice Aardvark - if `name` was not included in `inputs` or `optional` we would need to access it via `data`
255
- ```
214
+ 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).
256
215
 
257
- Because handlers are run in the context of the data carrier, you do not have direct access to methods or properties on your task object. However, the data carrier holds a reference to your task; use `task.do_something` or `task.some_attribute` to access it. The exception is the `fail_with`, `call` and `start` methods which the data carrier understands (and are intercepted when you are [testing](#testing)).
216
+ 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`).
258
217
 
259
- Both your task's `data` and its final `results` are stored in the database, so they can be examined later. The `results` because that's what you're interested in, the `data` as it can be useful for debugging or auditing purposes.
218
+ [Example wait and interaction handlers](spec/examples/waiting_and_interactions_spec.rb)
260
219
 
261
- They are both stored as hashes that are encoded into JSON.
220
+ ### Sub tasks
262
221
 
263
- Instead of using the standard [JSON coder](https://api.rubyonrails.org/v4.2/classes/ActiveModel/Serializers/JSON.html), we use a [GlobalIdSerialiser](https://github.com/standard-procedure/global_id_serialiser). This serialises most data into standard JSON types, as you would expect, but it also takes any [GlobalID::Identification](https://github.com/rails/globalid) objects (which includes all ActiveRecord models) and converts them to a GlobalID string. Then when the data is deserialised from the database, the GlobalID is converted back into the appropriate model.
222
+ If your task needs to start sub-tasks, it can use the `start` method, passing the sub-task class and arguments.
264
223
 
265
- If the original database record was deleted between the time the hash was serialised and when it was retrieved, the `GlobalID::Locator` will fail. In this case, the deserialised data will contain a `nil` for the value in question.
224
+ ```ruby
225
+ action :start_sub_tasks do
226
+ 3.times { |i| start OtherThingTask, number: i }
227
+ end
228
+ ```
229
+ Sub-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.
266
230
 
267
- Also note that the GlobalIdSerialiser automatically converts all hash keys into symbols (unlike the standard JSON coder which uses strings).
231
+ ```ruby
232
+ wait_until :sub_tasks_have_completed? do
233
+ condition { sub_tasks.all? { |st| st.completed? } }
234
+ go_to :all_sub_tasks_completed
235
+ condition { sub_tasks.any? { |st| st.failed? } }
236
+ go_to :some_sub_tasks_failed
237
+ end
238
+ ```
268
239
 
269
240
  #### Indexing data and results
270
241
 
271
- If you need to search through existing tasks by a model that is stored in the `data` or `results` fields - for example, you might want to list all operations that were started by a particular `User` - the models can be indexed alongside the task.
242
+ 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).
272
243
 
273
- If your ActiveRecord model (in this example, `User`) includes the `Operations::Participant` module, it will be linked with any task that references that model. A polymorphic join table, `operations_task_participants` is used for this. Whenever a task is saved, any `Operations::Participant` records are located in the `data` and `results` collections and a `Operations::TaskParticipant` record created to join the model to the task. The `context` attribute records whether the association is in the `data` or `results` collection and the `role` attribute is the name of the hash key.
244
+ For example:
274
245
 
275
- For example, you create your task as:
276
246
  ```ruby
277
- @alice = User.find 123
278
- @task = DoSomethingImportant.call user: @alice
279
- ```
280
- There will be a `TaskParticipant` record with a `context` of "data", `role` of "user" and `participant` of `@alice`.
247
+ class IndexesModelsTask < Operations::Task
248
+ has_model :user, "User"
249
+ validates :user, presence: true
250
+ has_models :documents, "Document"
251
+ has_attribute :count, :integer, default: 0
252
+ index :user, :documents
253
+ ...
254
+ end
281
255
 
282
- Likewise, you can see all the tasks that Alice was involved with using:
283
- ```ruby
284
- @alice.involved_in_operations_as("user") # => collection of tasks where Alice was a "user" in the "data" collection
285
- @alice.involved_in_operations_as("user", context: "results") # => collection of tasks where Alice was a "user" in the "results" collection
256
+ @task = IndexesModelsTask.call user: @user, documents: [@document1, @document2]
257
+
258
+ @user.operations.include?(@task) # => true
259
+ @user.operations_as(:user).include?(@task) # => true
260
+ @user.operations_as(:documents).include?(@task) # => false - the user is stored in the user attribute, not the documents attribute
286
261
  ```
287
262
 
288
263
  ### Failures and exceptions
289
264
 
290
- If any handlers raise an exception, the task will be terminated. It will be marked as `failed?` and the `results` hash will contain `results[:failure_message]`, `results[:exception_class]` and `results[:exception_backtrace]` for the exception's message, class name and backtrace respectively.
291
-
292
- You can also stop a task at any point by calling `fail_with message`. This will raise an `Operations::Failure` exception, marking the task as `failed?` and the `results` has will contain `results[:failure_message]`.
265
+ 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`.
293
266
 
294
267
  ### Task life-cycle and the database
295
268
 
@@ -298,61 +271,16 @@ There is an ActiveRecord migration that creates the `operations_tasks` table. U
298
271
  When you `call` a task, it is written to the database. Then whenever a state transition occurs, the task record is updated.
299
272
 
300
273
  This gives you a number of possibilities:
301
- - you can access the data and results (or error state) of a task after it has completed
302
- - you can use [TurboStream broadcasts](https://turbo.hotwired.dev/handbook/streams) to update your user-interface as the state changes - see "[status messages](#status-messages)" below
303
- - tasks can run in the background (using ActiveJob) and other parts of your code can interact with them whilst they are in progress - see "[background operations](#background-operations-and-pauses)" below
274
+ - you can access the data (or error state) of a task after it has completed
275
+ - you can use [TurboStream broadcasts](https://turbo.hotwired.dev/handbook/streams) to update your user-interface as the state changes
276
+ - tasks can wait until an external event of some kind
304
277
  - the tasks table acts as an audit trail or activity log for your application
305
278
 
306
- 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 calling `Operations::Task.delete_after 7.days` (or whatever value you prefer) in an initializer. Then, run a cron job, or other scheduled task, once per day that calls `Operations::Task.delete_expired`. This will delete any tasks whose `delete_at` time has passed.
307
-
308
- ### Sub tasks
309
-
310
- Any operation can be composed out of other operations and can therefore call other subtasks.
311
-
312
- ```ruby
313
- class PrepareDownload < Operations::Task
314
- inputs :user, :document
315
- starts_with :get_authorisation
316
-
317
- action :get_authorisation do
318
- inputs :user, :document
319
-
320
- result = call GetAuthorisation, user: user, document: document
321
- self.authorised = result[:authorised]
322
- end
323
- go_to :whatever_happens_next
324
- end
325
- ```
326
- If the sub-task succeeds, `call` returns the results from the sub-task. If it fails, then any exceptions are re-raised.
327
-
328
- You can also access the results in a block:
329
- ```ruby
330
- class PrepareDownload < Operations::Task
331
- inputs :user, :document
332
- starts_with :get_authorisation
333
-
334
- action :get_authorisation do
335
- inputs :user, :document
336
-
337
- call GetAuthorisation, user: user, document: document do |results|
338
- self.authorised = results[:authorised]
339
- end
340
- end
341
- go_to :whatever_happens_next
342
- end
343
- ```
344
-
345
- ### Agents
346
-
347
- So far, we've only defined tasks that run and complete immediately. However, [agents](/docs/agents.md) run over a long period of time and can respond to external interactions.
279
+ 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.
348
280
 
349
281
  ## Testing
350
282
 
351
- Tasks represent complex flows of logic, so each state can be [tested in isolation](/docs/testing.md).
352
-
353
- ## Visualisation
354
-
355
- There is a very simple [visualisation tool](/docs/visualisation.md) built into the gem.
283
+ TBD
356
284
 
357
285
  ## Installation
358
286
  Step 1: Add the gem to your Rails application's Gemfile:
@@ -0,0 +1,5 @@
1
+ class Operations::DeleteOldTaskJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform(task) = task.destroy
5
+ end
@@ -0,0 +1,5 @@
1
+ class Operations::WakeTaskJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform(task) = task.wake_up!
5
+ end
@@ -3,15 +3,10 @@ module Operations
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
- has_many :operations_task_participants, -> { includes(:task).order "created_at desc" }, class_name: "Operations::TaskParticipant", as: :participant, dependent: :destroy
7
- has_many :operations_tasks, class_name: "Operations::Task", through: :operations_task_participants, source: :task
8
-
9
- scope :involved_in_operation_as, ->(role:, context: "data") do
10
- joins(:operations_task_participants).tap do |scope|
11
- scope.where(operations_task_participants: {role: role}) if role
12
- scope.where(operations_task_participants: {context: context}) if context
13
- end
14
- end
6
+ has_many :operations_participants, class_name: "Operations::TaskParticipant", as: :participant, dependent: :destroy
7
+ has_many :operations, class_name: "Operations::Task", through: :operations_participants, source: :task
15
8
  end
9
+
10
+ def operations_as(attribute_name) = operations.joins(:participants).where(participants: {attribute_name: attribute_name, participant: self})
16
11
  end
17
12
  end
@@ -0,0 +1,22 @@
1
+ module Operations::Task::Index
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ def index(*names) = @indexed_attributes = (@indexed_attributes || []) + names.map(&:to_sym)
6
+
7
+ def indexed_attributes = @indexed_attributes ||= []
8
+ end
9
+
10
+ included do
11
+ has_many :participants, class_name: "Operations::TaskParticipant", dependent: :destroy
12
+ after_save :update_index, if: -> { indexed_attributes.any? }
13
+ end
14
+
15
+ private def indexed_attributes = self.class.indexed_attributes
16
+ private def update_index = indexed_attributes.collect { |attribute| update_index_for(attribute) }
17
+ private def update_index_for(attribute)
18
+ models = Array.wrap(send(attribute))
19
+ participants.where(attribute_name: attribute).where.not(participant: models).delete_all
20
+ models.collect { |model| participants.where(participant: model, attribute_name: attribute).first_or_create! }
21
+ end
22
+ end
@@ -3,17 +3,18 @@ class Operations::Task::Plan::ActionHandler
3
3
 
4
4
  def initialize name, &action
5
5
  @name = name.to_sym
6
- @required_inputs = []
7
- @optional_inputs = []
8
6
  @action = action
9
7
  @next_state = nil
10
8
  end
11
9
 
10
+ def then next_state
11
+ @next_state = next_state
12
+ end
13
+
12
14
  def immediate? = true
13
15
 
14
- def call(task, data)
15
- data.instance_exec(&@action).tap do |result|
16
- data.go_to @next_state unless @next_state.nil?
17
- end
16
+ def call(task)
17
+ task.instance_exec(&@action)
18
+ task.go_to @next_state
18
19
  end
19
20
  end
@@ -1,6 +1,4 @@
1
1
  class Operations::Task::Plan::DecisionHandler
2
- include Operations::Task::InputValidation
3
-
4
2
  def initialize name, &config
5
3
  @name = name.to_sym
6
4
  @conditions = []
@@ -12,40 +10,31 @@ class Operations::Task::Plan::DecisionHandler
12
10
 
13
11
  def immediate? = true
14
12
 
15
- def condition(destination = nil, options = {}, &condition)
13
+ def condition(&condition)
16
14
  @conditions << condition
17
- @destinations << destination if destination
18
- @condition_labels ||= {}
19
- condition_index = @conditions.size - 1
20
- @condition_labels[condition_index] = options[:label] if options[:label]
21
15
  end
22
16
 
23
17
  def go_to(destination) = @destinations << destination
24
18
 
25
- def condition_labels
26
- @condition_labels ||= {}
27
- end
28
-
29
19
  def if_true(state = nil, &handler) = @true_state = state || handler
30
20
 
31
21
  def if_false(state = nil, &handler) = @false_state = state || handler
32
22
 
33
- def call(task, data)
34
- validate_inputs! data.to_h
35
- has_true_false_handlers? ? handle_single_condition(task, data) : handle_multiple_conditions(task, data)
23
+ def call(task)
24
+ has_true_false_handlers? ? handle_single_condition(task) : handle_multiple_conditions(task)
36
25
  end
37
26
 
38
27
  private def has_true_false_handlers? = !@true_state.nil? || !@false_state.nil?
39
28
 
40
- private def handle_single_condition(task, data)
41
- next_state = data.instance_eval(&@conditions.first) ? @true_state : @false_state
42
- next_state.respond_to?(:call) ? data.instance_eval(&next_state) : data.go_to(next_state, data)
29
+ private def handle_single_condition(task)
30
+ next_state = task.instance_eval(&@conditions.first) ? @true_state : @false_state
31
+ task.go_to(next_state)
43
32
  end
44
33
 
45
- private def handle_multiple_conditions(task, data)
46
- condition = @conditions.find { |condition| data.instance_eval(&condition) }
34
+ private def handle_multiple_conditions(task)
35
+ condition = @conditions.find { |condition| task.instance_eval(&condition) }
47
36
  raise Operations::NoDecision.new("No conditions matched #{@name}") if condition.nil?
48
37
  index = @conditions.index condition
49
- data.go_to(@destinations[index])
38
+ task.go_to(@destinations[index])
50
39
  end
51
40
  end
@@ -0,0 +1,20 @@
1
+ class Operations::Task::Plan::InteractionHandler
2
+ def initialize name, klass, &implementation
3
+ @legal_states = []
4
+ build_method_on klass, name, self, implementation
5
+ end
6
+ attr_reader :legal_states
7
+
8
+ def when *legal_states
9
+ @legal_states = legal_states.map(&:to_s).freeze
10
+ end
11
+
12
+ private def build_method_on klass, name, handler, implementation
13
+ klass.define_method name.to_sym do |*args|
14
+ raise Operations::InvalidState.new("#{klass}##{name} cannot be called in #{current_state}") if handler.legal_states.any? && !handler.legal_states.include?(current_state.to_s)
15
+ Rails.logger.debug { "interaction #{name} with #{self}" }
16
+ instance_exec(*args, &implementation)
17
+ wake_up!
18
+ end
19
+ end
20
+ end
@@ -1,16 +1,9 @@
1
1
  class Operations::Task::Plan::ResultHandler
2
- def initialize name, inputs = [], optional = [], &handler
2
+ def initialize name
3
3
  @name = name.to_sym
4
- @required_inputs = inputs
5
- @optional_inputs = optional
6
- @handler = handler
7
4
  end
8
5
 
9
6
  def immediate? = true
10
7
 
11
- def call(task, data)
12
- results = OpenStruct.new
13
- data.instance_exec(results, &@handler) unless @handler.nil?
14
- data.complete(results)
15
- end
8
+ def call(task) = task.update task_status: "completed", completed_at: Time.current
16
9
  end