standard_procedure_operations 0.2.4 → 0.2.6

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 227d7875d414fd181b6f620f403dbdd3d4fb098e86853563c328d871f521cc33
4
- data.tar.gz: 1512b85b79a129784ecfdea2d2c82c3ea842aca78ce69c4c6b1a1e65662b9ea4
3
+ metadata.gz: abbdad16738becd8b42eefb407d07e199bd4ab02b3590cdc5e14a605a8e9a8a2
4
+ data.tar.gz: f78887c11e55d0542d0b6f32435de1b07132879248dfccc9349f7b87e2746000
5
5
  SHA512:
6
- metadata.gz: e4f1e35fb4ffffbf58cae1e98ead209656542115c9a05cc6b6d7122a8659a63ae82599700fa804650c8539fe38f980ad77521b2ad4744734326813f6fa44aaaf
7
- data.tar.gz: 53c72faa3640bc85f6283ae4bb8ff5aaac2d462994cff1b4654d84c88642039fb62082dcdf212d7c643edd4d2d84d37f85ea34c0f1259333bb44a0cca1e28b89
6
+ metadata.gz: e1d7e6702dce6caaeaae519e72dbad953b9a597e70863d8dc222f9db08c9292c8736b9e3f16059567d6b972754272aa7e11dffd9ab73f4168ed178ce286030d2
7
+ data.tar.gz: d1c4e2642723b131264dc6421755cd781f40ec6a1da78b385ac4b53bd11ad52901f1cbeacfd5b76620076ed3f3adc33a56088c5c764d9ba1cf1a05c310c03615
data/README.md CHANGED
@@ -49,6 +49,7 @@ class PrepareDocumentForDownload < Operations::Task
49
49
 
50
50
  decision :authorised? do
51
51
  inputs :user
52
+ condition { user.can?(:read, data.document) }
52
53
 
53
54
  if_true :within_download_limits?
54
55
  if_false { fail_with "unauthorised" }
@@ -56,6 +57,7 @@ class PrepareDocumentForDownload < Operations::Task
56
57
 
57
58
  decision :within_download_limits? do
58
59
  inputs :user
60
+ condition { user.within_download_limits? }
59
61
 
60
62
  if_true :use_filename_scrambler?
61
63
  if_false { fail_with "download_limit_reached" }
@@ -82,21 +84,19 @@ class PrepareDocumentForDownload < Operations::Task
82
84
 
83
85
  results.filename = filename || document.filename.to_s
84
86
  end
85
-
86
- private def authorised?(data) = data.user.can?(:read, data.document)
87
- private def within_download_limits?(data) = data.user.within_download_limits?
88
87
  end
88
+
89
+ task = PrepareDocumentForDownload.call user: @user, document: @document, use_filename_scrambler: @account.feature_flags[:use_filename_scramber]
90
+ puts task.results[:filename]
89
91
  ```
90
92
 
91
- The five states are represented as three [decision](#decisions) handlers, one [action](#actions) handler and a [result](#results) handler.
93
+ The task declares that it requires `user`, `document` and `use_filename_scrambler` parameters and that it starts in the `authorised?` state.
92
94
 
93
- The task also declares that it requires a `user`, `document` and `use_filename_scrambler` parameter to be provided, and also declares its initial state - `authorised?`.
95
+ The five states are represented as three [decision](#decisions) handlers, one [action](#actions) handler and a [result](#results) handler.
94
96
 
95
97
  ### Decisions
96
98
  A decision handler evaluates a condition, then changes state depending upon if the result is true or false.
97
99
 
98
- It's up to you whether you define the condition as a block, as part of the decision handler, or as a method on the task object.
99
-
100
100
  ```ruby
101
101
  decision :is_it_the_weekend? do
102
102
  condition { Date.today.wday.in? [0, 6] }
@@ -105,40 +105,29 @@ decision :is_it_the_weekend? do
105
105
  if_false :go_to_work
106
106
  end
107
107
  ```
108
- Or
109
- ```ruby
110
- decision :is_it_the_weekend? do
111
- if_true :have_a_party
112
- if_false :go_to_work
113
- end
114
-
115
- def is_it_the_weekend?(data)
116
- Date.today.wday.in? [0, 6]
117
- end
118
- ```
119
-
120
- A decision can also mark a failure, which will terminate the task.
108
+ A decision can also mark a failure, which will terminate the task and raise an `Operations::Failure`.
121
109
  ```ruby
122
110
  decision :authorised? do
123
111
  condition { user.administrator? }
112
+
124
113
  if_true :do_some_work
125
114
  if_false { fail_with "Unauthorised" }
126
115
  end
127
116
  ```
117
+ (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).
128
118
 
129
119
  You can specify the data that is required for a decision handler to run by specifying `inputs` and `optionals`:
130
120
  ```ruby
131
121
  decision :authorised? do
132
- inputs :user
133
- optionals :override
134
-
122
+ inputs :user
123
+ optional :override
135
124
  condition { override || user.administrator? }
136
125
 
137
126
  if_true :do_some_work
138
127
  if_false { fail_with "Unauthorised" }
139
128
  end
140
129
  ```
141
- In this case, the task will fail if there is no `user` specified. However, `override` is optional (and in fact the `optional` method is just there to help you document your operations).
130
+ In this case, the task will fail (with an `ArgumentError`) if there is no `user` specified. However, `override` is optional (in fact the `optional` method does nothing and is just there for documentation purposes).
142
131
 
143
132
  ### Actions
144
133
  An action handler does some work, then moves to another state.
@@ -148,51 +137,37 @@ action :have_a_party do
148
137
  self.food = task.buy_some_food_for(number_of_guests)
149
138
  self.beer = task.buy_some_beer_for(number_of_guests)
150
139
  self.music = task.plan_a_party_playlist
140
+
151
141
  go_to :send_invitations
152
142
  end
153
143
  ```
154
- You can specify the required and optional data for your action handler within the block. `optional` is decorative and to help with your documentation. Ensure you call `inputs` at the start of the block.
144
+ You can specify the required and optional data for your action handler within the block. `optional` is decorative and to help with your documentation. Ensure you call `inputs` at the start of the block so that the task fails before you do any meaningful work.
155
145
 
156
146
  ```ruby
157
147
  action :have_a_party do
158
- inputs :task
148
+ inputs :number_of_guests
159
149
  optional :music
160
150
 
161
151
  self.food = task.buy_some_food_for(number_of_guests)
162
152
  self.beer = task.buy_some_beer_for(number_of_guests)
163
153
  self.music ||= task.plan_a_party_playlist
164
- go_to :send_invitations
165
- end
166
- ```
167
154
 
168
- Again, instead of using a block in the action handler, you could provide a method to do the work. However, you cannot specify `inputs` or `optional` data when using a method.
169
-
170
- ```ruby
171
- action :have_a_party
172
-
173
- def have_a_party(data)
174
- data.food = buy_some_food_for(data.number_of_guests)
175
- data.beer = buy_some_beer_for(data.number_of_guests)
176
- data.music = plan_a_party_playlist
177
155
  go_to :send_invitations
178
156
  end
179
157
  ```
180
- Note that when using a method you need to refer to the `data` parameter directly, when using a block, you need to refer to the `task` - see the section on "[Data](#data-and-results)" for more information.
181
-
182
- Do not forget to call `go_to` from your action handler, otherwise the operation will just stop whilst still being marked as in progress.
158
+ Do not forget to call `go_to` from your action handler, otherwise the operation will just stop whilst still being marked as in progress. (TODO: don't let this happen).
183
159
 
184
160
  ### Results
185
- A result handler marks the end of an operation, optionally returning some results. You 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 in the database (as many operations may have a large set of working data).
186
-
187
- There is no method equivalent to a block handler.
161
+ A result handler marks the end of an operation, optionally returning some results. You 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 (and your data, effectively your "working memory", can be safely discarded).
188
162
 
189
163
  ```ruby
190
164
  action :send_invitations do
191
165
  self.invited_friends = (0..number_of_guests).collect do |i|
192
166
  friend = friends.pop
193
- FriendsMailer.with(recipient: friend).party_invitation.deliver_later
167
+ FriendsMailer.with(recipient: friend).party_invitation.deliver_later unless friend.nil?
194
168
  friend
195
- end
169
+ end.compact
170
+
196
171
  go_to :ready_to_party
197
172
  end
198
173
 
@@ -211,12 +186,14 @@ In this case, the task will be marked as `completed?`, the task's state will be
211
186
  You can also specify the required and optional data for your result handler within the block. `optional` is decorative and to help with your documentation. Ensure you call `inputs` at the start of the block.
212
187
  ```ruby
213
188
  action :send_invitations do
214
- inputs :number_of_guests
189
+ inputs :number_of_guests, :friends
190
+
215
191
  self.invited_friends = (0..number_of_guests).collect do |i|
216
192
  friend = friends.pop
217
- FriendsMailer.with(recipient: friend).party_invitation.deliver_later
193
+ FriendsMailer.with(recipient: friend).party_invitation.deliver_later unless friend.nil?
218
194
  friend
219
- end
195
+ end.compact
196
+
220
197
  go_to :ready_to_party
221
198
  end
222
199
 
@@ -225,6 +202,7 @@ result :ready_to_party do |results|
225
202
 
226
203
  results.invited_friends = invited_friends
227
204
  end
205
+ ```
228
206
 
229
207
  ### Calling an operation
230
208
  You would use the earlier [PrepareDocumentForDownload](spec/examples/prepare_document_for_download_spec.rb) operation in a controller like this:
@@ -235,38 +213,70 @@ class DownloadsController < ApplicationController
235
213
  @document = Document.includes(:account).find(params[:id])
236
214
  @task = PrepareDocumentForDownload.call(user: Current.user, document: @document, use_filename_scrambler: @document.account.use_filename_scrambler?)
237
215
  if @task.completed?
238
- @filename = @task.results.filename
216
+ @filename = @task.results[:filename]
239
217
  send_data @document.contents, filename: @filename, disposition: "attachment"
240
218
  else
241
- render action: "error", message: @task.results.failure_message, status: 401
219
+ render action: "error", message: @task.results[:failure_message], status: 401
242
220
  end
243
221
  end
244
222
  end
245
223
  ```
246
224
 
247
- OK - so that's a pretty longwinded way of performing a simple task. But, in Collabor8Online, the actual operation for handling downloads has over twenty states, with half of them being decisions (as there are a number of feature flags and per-account configuration options). When you get to complex decision trees like that, being able to lay them out as state transitions becomes invaluable.
225
+ Future dev: I'm going to change this so it raises an exception on failure so it will become:
226
+ ```ruby
227
+ class DownloadsController < ApplicationController
228
+ def show
229
+ @document = Document.find(params[:id])
230
+ @task = PrepareDocumentForDownload.call(user: Current.user, document: @document, use_filename_scrambler: Current.account.use_filename_scrambler?)
231
+
232
+ send_data @document.contents, filename: @task.results[:filename], disposition: "attachment"
233
+
234
+ rescue => failure
235
+ render action: "error", locals: {error: failure.message}, status: 422
236
+ end
237
+ end
238
+ ```
239
+
240
+ OK - so that's a pretty longwinded way of performing a simple task. But, in Collabor8Online, the actual operation for handling downloads has over twenty states, with half of them being decisions (as there are a number of feature flags and per-account configuration options). Originally these were spread across multiple controllers, models and other objects. Now they are centralised in a single "operations map" that describes the flowchart used to prepare a document for download - invaluable for comprehension of complex logic.
248
241
 
249
242
  ### Data and results
250
- Each operation carries its own, mutable, data for the duration of the operation. This is provided when you `call` the operation to start it and is passed through to each decision, action and result. This data is transient and not stored in the database. If you modify the data then that modification is passed on to the next handler.
243
+ Each operation carries its own, mutable, [data](/app/models/operations/task/data_carrier.rb) for the duration of the operation.
244
+
245
+ This is provided when you `call` the operation to start it and is passed through to each decision, action and result. This data is transient and not stored in the database. If you modify the data then that modification is passed on to the next handler. (Note - when background tasks are implemented, we may end up storing the data in the database).
251
246
 
252
- For example, in the [DownloadsController](#calling-an-operation) shown above, the `user`, `document` and `use_filename_scrambler` are set within the data object when the operation is started. But if the `scramble_filename` action is called, it generates a new filename and adds that to the data object as well. Finally the `return_filename` result handler then returns either the scrambled or the original filename to the caller.
247
+ 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`.
248
+
249
+ ```ruby
250
+ class CombineNames < Operations::Task
251
+ inputs :first_name, :last_name
252
+ starts_with :build_name
253
253
 
254
- Within handlers implemented as blocks, you can read the data directly - for example, `condition { use_filename_scrambler }` from the `use_filename_scrambler?` decision shown earlier. If you want to modify a value, or add a new one, you must use `self` - `self.my_data = "something important"`.
254
+ action :build_name do
255
+ self.name = "#{first_name} #{last_name}"
256
+ go_to :done
257
+ end
255
258
 
256
- This is because the data is carried using a [DataCarrier](/app/models/operations/task/data_carrier.rb) object and `instance_eval` is used within your block handlers.
259
+ result :done do |results|
260
+ results.name = name
261
+ end
262
+ end
257
263
 
258
- This also means that block handlers must use `task.method` to access methods or data on the task object itself (as you are not actually within the context of the task object itself). The exceptions are the `go_to` and `fail_with` methods which the data carrier forwards to the task.
264
+ task = CombineNames.call first_name: "Alice", last_name: "Aardvark"
265
+ task.results[:name] # => Alice Aardvark
266
+ ```
259
267
 
260
- Handlers can alternatively be implemented as methods on the task itself. This means that they are executed within the context of the task and can methods and variables belonging to the task. Each handler method receives a `data` parameter which is the data carrier for that task. Individual items can be accessed as a hash - `data[:my_item]` - or as an attribute - `data.my_item`.
268
+ Because handlers are run in the context of the data carrier, this means you do not have direct access to methods or properties on your task object. So you need to use `task` to access it - `task.do_something` or `task.some_attribute`. The exceptions are the `go_to` and `fail_with` methods which the data carrier forwards to the task (and the `TestResultCarrier` intercepts when you are testing your operation).
261
269
 
262
- The final `results` data from any `result` handlers is stored, along with the task, in the database, so it can be examined later. It is a Hash that is encoded into JSON with any ActiveRecord models translated using a [GlobalID](https://github.com/rails/globalid) (this uses [ActiveJob::Arguments](https://guides.rubyonrails.org/active_job_basics.html#supported-types-for-arguments) so works the same way as passing models to ActiveJob).
270
+ The final `results` data from any `result` handlers is stored, along with the task, in the database, so it can be examined later. It is a Hash that is encoded into JSON with any ActiveRecord models translated using a [GlobalID](https://github.com/rails/globalid) (this uses [ActiveJob::Arguments](https://guides.rubyonrails.org/active_job_basics.html#supported-types-for-arguments) so works the same way as passing data to ActiveJob).
263
271
 
264
- Be aware that if you do store an ActiveRecord model into your `results` and that model is later deleted from the database, your task's `results` will be unavailable (as `GlobalID::Locator` will fail when it tries to load the record). The data is not lost though - if the deserialisation fails, the routine will return the JSON string as `results.raw_data`.
272
+ Be aware that if you do store an ActiveRecord model into your `results` and that model is later deleted from the database, your task's `results` will be unavailable (as `GlobalID::Locator` will fail when it tries to load the record). The data is not lost though - if the deserialisation fails, the routine will return the JSON string as `results[:raw_data]`.
265
273
 
266
274
  ### Failures and exceptions
267
275
  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.
268
276
 
269
- You can also stop a task at any point by calling `fail_with message`. This will mark the task as `failed?` and the `reeults` has will contain `results[:failure_message]`.
277
+ You can also stop a task at any point by calling `fail_with message`. This will mark the task as `failed?` and the `results` has will contain `results[:failure_message]`.
278
+
279
+ (Future dev - going to change this so it raises an exception (or passes it on to the caller) so you don't need to test for `failed?` every time - this will simplify managing sub-tasks).
270
280
 
271
281
  ### Task life-cycle and the database
272
282
  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.
@@ -279,13 +289,48 @@ This gives you a number of possibilities:
279
289
  - 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
280
290
  - the tasks table acts as an audit trail or activity log for your application
281
291
 
282
- 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). Then, run a cron job (once per day) that calls `Operations::Task.delete_expired`, removing any tasks whose `deleted_at` date has passed.
292
+ 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.
283
293
 
284
294
  ### Status messages
285
295
  Documentation coming soon.
286
296
 
287
- ### Child tasks
288
- Coming soon.
297
+ ### Sub tasks
298
+ Any operation can be composed out of other operations and can therefore call other subtasks.
299
+
300
+ ```ruby
301
+ class PrepareDownload < Operations::Task
302
+ inputs :user, :document
303
+ starts_with :get_authorisation
304
+
305
+ action :get_authorisation do
306
+ inputs :user, :document
307
+
308
+ results = call GetAuthorisation, user: user, document: document
309
+ self.authorised = results[:authorised]
310
+
311
+ go_to :whatever_happens_next
312
+ end
313
+ end
314
+ ```
315
+ If the sub-task succeeds, `call` returns the results from the sub-task. If it fails, then any exceptions are re-raised.
316
+
317
+ You can also access the results in a block:
318
+ ```ruby
319
+ class PrepareDownload < Operations::Task
320
+ inputs :user, :document
321
+ starts_with :get_authorisation
322
+
323
+ action :get_authorisation do
324
+ inputs :user, :document
325
+
326
+ call GetAuthorisation, user: user, document: document do |results|
327
+ self.authorised = results[:authorised]
328
+ end
329
+
330
+ go_to :whatever_happens_next
331
+ end
332
+ end
333
+ ```
289
334
 
290
335
  ### Background operations and pauses
291
336
  Coming soon.
@@ -293,8 +338,13 @@ Coming soon.
293
338
  ## Testing
294
339
  Because operations are intended to model long, complex, flowcharts of decisions and actions, it can be a pain coming up with the combinations of inputs to test every path through the sequence.
295
340
 
296
- Instead, you can test each state handler in isolation. As the handlers are state-less, we can simulate calling one by creating a task object and then calling the appropriate handler with the data that it expects. This is done by calling `handling`, which yields a `test` object with outcomes from the handler that we can inspect
341
+ Instead, you can test each state handler _in isolation_.
342
+
343
+ As the handlers are stateless, we can call one without hitting the database; instead creating a dummy task object and then triggering the handler with the correct parameters.
344
+
345
+ This is done by calling `handling`, which yields a `test` object that we can inspect.
297
346
 
347
+ ### Testing state transitions
298
348
  To test if we have moved on to another state (for actions or decisions):
299
349
  ```ruby
300
350
  MyOperation.handling(:an_action_or_decision, some: "data") do |test|
@@ -303,19 +353,23 @@ MyOperation.handling(:an_action_or_decision, some: "data") do |test|
303
353
  expect(test).to have_moved_to "new_state"
304
354
  end
305
355
  ```
356
+
357
+ ### Testing data modifications
306
358
  To test if some data has been set or modified (for actions):
307
359
  ```ruby
308
360
  MyOperation.handling(:an_action, existing_data: "some_value") do |test|
309
- # has a new data value been added?
310
- assert_equal test.new_data, "new_value"
311
- # or
312
- expect(test.new_data).to eq "new_value"
313
361
  # has an existing data value been modified?
314
362
  assert_equal test.existing_data, "some_other_value"
315
363
  # or
316
364
  expect(test.existing_data).to eq "some_other_value"
365
+ # has a new data value been added?
366
+ assert_equal test.new_data, "new_value"
367
+ # or
368
+ expect(test.new_data).to eq "new_value"
317
369
  end
318
370
  ```
371
+
372
+ ### Testing results
319
373
  To test the results from a result handler:
320
374
  ```ruby
321
375
  MyOperation.handling(:a_result, some: "data") do |test|
@@ -330,6 +384,27 @@ end
330
384
  ```
331
385
  (Note - although results are stored in the database as a Hash, within your test, the results object is still carried as an OpenStruct, so you can access it using either notation).
332
386
 
387
+ ### Testing sub-tasks
388
+ ```ruby
389
+ MyOperation.handling(:a_sub_task, some: "data") do |test|
390
+ # Test which sub-tasks were called
391
+ assert_includes test.sub_tasks.keys, MySubTask
392
+ # or
393
+ expect(test.sub_tasks).to include MySubTask
394
+ end
395
+ ```
396
+ TODO: I'm still figuring out how to test the data passed to sub-tasks. And calling a sub-task will actually execute that sub-task, so you need to stub `MySubTask.call` if it's an expensive operation.
397
+
398
+ ```ruby
399
+ # Sorry, don't know the Minitest syntax for this
400
+ @sub_task = double "Operations::Task", results: { some: "answers" }
401
+ allow(MySubTask).to receive(:call).and_return(@sub_task)
402
+
403
+ MyOperation.handling(:a_sub_task, some: "data") do |test|
404
+ expect(test.sub_tasks).to include MySubTask
405
+ end
406
+ ```
407
+ ### Testing failures
333
408
  To test if a handler has failed:
334
409
  ```ruby
335
410
  MyOperation.handling(:a_failure, some: "data") do |test|
@@ -338,6 +413,8 @@ MyOperation.handling(:a_failure, some: "data") do |test|
338
413
  expect(test).to have_failed_with "oh dear"
339
414
  end
340
415
  ```
416
+ (Future dev: exceptions will not be silently caught so this will probably become `expect { MyOperation.handling(:a_failure, some: "data") }.to raise_exception(Whatever)).
417
+
341
418
  If you are using RSpec, you must `require "operations/matchers"` to make the matchers available to your specs.
342
419
 
343
420
  ## Installation
@@ -356,14 +433,14 @@ class DailyLife < Operations::Task
356
433
  starts_with :am_i_awake?
357
434
 
358
435
  decision :am_i_awake? do
436
+ condition { (7..23).include?(Time.now.hour) }
437
+
359
438
  if_true :live_like_theres_no_tomorrow
360
439
  if_false :rest_and_recuperate
361
440
  end
362
441
 
363
442
  result :live_like_theres_no_tomorrow
364
443
  result :rest_and_recuperate
365
-
366
- def am_i_awake? = (7..23).include?(Time.now.hour)
367
444
  end
368
445
  ```
369
446
  Step 4: If you're using RSpec for testing, add `require "operations/matchers" to your "spec/rails_helper.rb" file.
@@ -374,9 +451,13 @@ The gem is available as open source under the terms of the [LGPL License](/LICEN
374
451
  ## Roadmap
375
452
 
376
453
  - [x] Specify inputs (required and optional) per-state, not just at the start
377
- - [ ] Always raise errors instead of just recording a failure (will be useful when dealing with sub-tasks)
378
- - [ ] Simplify calling sub-tasks (and testing the same)
454
+ - [x] Always raise errors instead of just recording a failure (will be useful when dealing with sub-tasks)
455
+ - [ ] Deal with actions that have forgotten to call `go_to` (probably related to future `pause` functionality)
456
+ - [x] Simplify calling sub-tasks (and testing them)
457
+ - [ ] Figure out how to stub calling sub-tasks with known results data
458
+ - [ ] Figure out how to test the parameters passed to sub-tasks when they are called
379
459
  - [ ] Split out the state-management definition stuff from the task class (so you can use it without subclassing Operations::Task)
380
460
  - [ ] Make Operations::Task work in the background using ActiveJob
381
461
  - [ ] Add pause/resume capabilities (for example, when a task needs to wait for user input)
382
462
  - [ ] Add wait for sub-tasks capabilities
463
+ - [ ] Maybe? Split this out into two gems - one defining an Operation (pure ruby) and another defining the Task (using ActiveJob as part of a Rails Engine)
@@ -3,6 +3,8 @@ class Operations::Task::DataCarrier < OpenStruct
3
3
 
4
4
  def fail_with(message) = task.fail_with(message)
5
5
 
6
+ def call(sub_task_class, **data, &result_handler) = task.call(sub_task_class, **data, &result_handler)
7
+
6
8
  def complete(results) = task.complete(results)
7
9
 
8
10
  def inputs(*names)
@@ -27,6 +27,7 @@ module Operations::Task::StateManagement
27
27
  handler_for(state).call(self, data)
28
28
  rescue => ex
29
29
  update! status: "failed", status_message: ex.message.to_s.truncate(240), results: {failure_message: ex.message, exception_class: ex.class.name, exception_backtrace: ex.backtrace}
30
+ raise ex
30
31
  end
31
32
  private def state_is_valid
32
33
  errors.add :state, :invalid if state.blank? || handler_for(state.to_sym).nil?
@@ -0,0 +1,7 @@
1
+ module Operations::Task::SubTasks
2
+ def call sub_task_class, **data, &result_handler
3
+ sub_task = sub_task_class.call(data)
4
+ result_handler&.call(sub_task.results)
5
+ sub_task.results
6
+ end
7
+ end
@@ -10,7 +10,7 @@ module Operations::Task::Testing
10
10
  end
11
11
  end
12
12
 
13
- class TestResultCarrier < OpenStruct
13
+ class TestResultCarrier < Operations::Task::DataCarrier
14
14
  def go_to(state, message = nil)
15
15
  self.next_state = state
16
16
  self.status_message = message || next_state.to_s
@@ -20,6 +20,12 @@ module Operations::Task::Testing
20
20
  self.failure_message = message
21
21
  end
22
22
 
23
+ def call(sub_task_class, **data, &result_handler)
24
+ self.sub_tasks ||= []
25
+ self.sub_tasks << sub_task_class
26
+ super
27
+ end
28
+
23
29
  def complete(results)
24
30
  self.completion_results = results
25
31
  end
@@ -1,6 +1,7 @@
1
1
  module Operations
2
2
  class Task < ApplicationRecord
3
3
  include StateManagement
4
+ include SubTasks
4
5
  include Deletion
5
6
  include Testing
6
7
  extend InputValidation
@@ -20,7 +21,10 @@ module Operations
20
21
  process_current_state(data)
21
22
  end
22
23
 
23
- def fail_with(message) = update! status: "failed", status_message: message.to_s.truncate(240), results: {failure_message: message.to_s}
24
+ def fail_with(message)
25
+ update! status: "failed", status_message: message.to_s.truncate(240), results: {failure_message: message.to_s}
26
+ raise Operations::Failure.new(message, self)
27
+ end
24
28
 
25
29
  def complete(results) = update!(status: "completed", status_message: "completed", results: results.to_h)
26
30
  end
@@ -0,0 +1,2 @@
1
+ class Operations::Failure < Operations::Error
2
+ end
@@ -1,3 +1,3 @@
1
1
  module Operations
2
- VERSION = "0.2.4"
2
+ VERSION = "0.2.6"
3
3
  end
data/lib/operations.rb CHANGED
@@ -2,9 +2,14 @@ require "ostruct"
2
2
 
3
3
  module Operations
4
4
  class Error < StandardError
5
+ def initialize message, task = nil
6
+ super(message)
7
+ @task = task
8
+ end
9
+ attr_reader :task
5
10
  end
6
11
  require "operations/version"
7
12
  require "operations/engine"
8
13
  require "operations/global_id_serialiser"
9
- require "operations/missing_inputs_error"
14
+ require "operations/failure"
10
15
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_procedure_operations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rahoul Baruah
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-02-03 00:00:00.000000000 Z
10
+ date: 2025-02-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -41,14 +41,15 @@ files:
41
41
  - app/models/operations/task/state_management/action_handler.rb
42
42
  - app/models/operations/task/state_management/completion_handler.rb
43
43
  - app/models/operations/task/state_management/decision_handler.rb
44
+ - app/models/operations/task/sub_tasks.rb
44
45
  - app/models/operations/task/testing.rb
45
46
  - config/routes.rb
46
47
  - db/migrate/20250127160616_create_operations_tasks.rb
47
48
  - lib/operations.rb
48
49
  - lib/operations/engine.rb
50
+ - lib/operations/failure.rb
49
51
  - lib/operations/global_id_serialiser.rb
50
52
  - lib/operations/matchers.rb
51
- - lib/operations/missing_inputs_error.rb
52
53
  - lib/operations/version.rb
53
54
  - lib/standard_procedure_operations.rb
54
55
  - lib/tasks/operations_tasks.rake
@@ -1,2 +0,0 @@
1
- class Operations::MissingInputsError < Operations::Error
2
- end