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.
- checksums.yaml +4 -4
- data/README.md +128 -200
- data/app/jobs/operations/delete_old_task_job.rb +5 -0
- data/app/jobs/operations/wake_task_job.rb +5 -0
- data/app/models/concerns/operations/participant.rb +4 -9
- data/app/models/operations/task/index.rb +22 -0
- data/app/models/operations/task/plan/action_handler.rb +7 -6
- data/app/models/operations/task/plan/decision_handler.rb +9 -20
- data/app/models/operations/task/plan/interaction_handler.rb +20 -0
- data/app/models/operations/task/plan/result_handler.rb +2 -9
- data/app/models/operations/{agent → task/plan}/wait_handler.rb +6 -6
- data/app/models/operations/task/plan.rb +44 -11
- data/app/models/operations/{agent → task}/runner.rb +4 -7
- data/app/models/operations/task.rb +48 -82
- data/app/models/operations/task_participant.rb +2 -7
- data/db/migrate/20250701190516_rename_existing_operations_tables.rb +19 -0
- data/db/migrate/20250701190716_create_new_operations_tasks.rb +20 -0
- data/db/migrate/20250702113801_create_task_participants.rb +10 -0
- data/lib/operations/version.rb +1 -1
- data/lib/operations.rb +1 -3
- metadata +12 -24
- data/app/jobs/operations/agent/find_timeouts_job.rb +0 -5
- data/app/jobs/operations/agent/runner_job.rb +0 -5
- data/app/jobs/operations/agent/timeout_job.rb +0 -5
- data/app/jobs/operations/agent/wake_agents_job.rb +0 -5
- data/app/models/operations/agent/interaction_handler.rb +0 -30
- data/app/models/operations/agent/plan.rb +0 -38
- data/app/models/operations/agent.rb +0 -31
- data/app/models/operations/task/data_carrier.rb +0 -16
- data/app/models/operations/task/deletion.rb +0 -17
- data/app/models/operations/task/exports.rb +0 -45
- data/app/models/operations/task/input_validation.rb +0 -17
- data/app/models/operations/task/testing.rb +0 -63
- data/db/migrate/20250127160616_create_operations_tasks.rb +0 -17
- data/db/migrate/20250309160616_create_operations_task_participants.rb +0 -15
- data/db/migrate/20250404085321_add_becomes_zombie_at_field.operations.rb +0 -7
- data/db/migrate/20250407143513_agent_fields.rb +0 -9
- data/db/migrate/20250408124423_add_task_participant_indexes.rb +0 -5
- data/lib/operations/exporters/svg.rb +0 -399
- data/lib/operations/has_data_attributes.rb +0 -50
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d5804e91c48ca11b9306c1d8d2c3006df7916dcd3e768629cf492b1daa409725
|
4
|
+
data.tar.gz: 2c0f4a513bb1bd685f44daf46f28e7ecb2ac0909a703ae79240ff0587fc74d1c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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!
|
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
|
69
|
+
expect(task).to be_in "go_to_work"
|
63
70
|
# If it's Sunday
|
64
|
-
expect(task
|
71
|
+
expect(task).to be_in "relax"
|
65
72
|
# If it's Saturday
|
66
|
-
expect(task
|
67
|
-
expect(task.
|
73
|
+
expect(task).to be_in "party!"
|
74
|
+
expect(task.available_friends).to_not be_empty
|
68
75
|
```
|
69
|
-
We define the `
|
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. `
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
148
|
+
A result handler marks the end of an operation. It's pretty simple.
|
164
149
|
|
165
150
|
```ruby
|
166
|
-
|
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
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
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
|
-
|
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
|
-
|
191
|
-
|
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
|
-
|
194
|
-
|
195
|
-
|
177
|
+
wait_until :name_provided? do
|
178
|
+
condition { name.present? }
|
179
|
+
go_to :create_user
|
180
|
+
end
|
196
181
|
|
197
|
-
|
182
|
+
interaction :register! do |name|
|
183
|
+
self.name = name
|
184
|
+
end.when :name_provided?
|
198
185
|
|
199
|
-
|
186
|
+
action :create_user do
|
187
|
+
self.user = User.create! name: name
|
188
|
+
end
|
189
|
+
go_to :done
|
200
190
|
|
201
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
200
|
+
Like decisions, use a question as the name for your wait handlers.
|
227
201
|
|
228
|
-
|
202
|
+
#### Interactions
|
229
203
|
|
230
|
-
|
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
|
-
|
206
|
+
As a convention, use an exclamation mark to name your interaction handlers.
|
233
207
|
|
234
|
-
|
208
|
+
#### Background processor
|
235
209
|
|
236
|
-
|
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
|
-
|
250
|
-
end
|
212
|
+
#### Starting tasks in the background
|
251
213
|
|
252
|
-
task
|
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
|
-
|
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
|
-
|
218
|
+
[Example wait and interaction handlers](spec/examples/waiting_and_interactions_spec.rb)
|
260
219
|
|
261
|
-
|
220
|
+
### Sub tasks
|
262
221
|
|
263
|
-
|
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
|
-
|
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
|
-
|
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
|
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
|
-
|
244
|
+
For example:
|
274
245
|
|
275
|
-
For example, you create your task as:
|
276
246
|
```ruby
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
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
|
-
|
283
|
-
|
284
|
-
@
|
285
|
-
@
|
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
|
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
|
302
|
-
- you can use [TurboStream broadcasts](https://turbo.hotwired.dev/handbook/streams) to update your user-interface as the state changes
|
303
|
-
- tasks can
|
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
|
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
|
-
|
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:
|
@@ -3,15 +3,10 @@ module Operations
|
|
3
3
|
extend ActiveSupport::Concern
|
4
4
|
|
5
5
|
included do
|
6
|
-
has_many :
|
7
|
-
has_many :
|
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
|
15
|
-
|
16
|
-
|
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(
|
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
|
34
|
-
|
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
|
41
|
-
next_state =
|
42
|
-
|
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
|
46
|
-
condition = @conditions.find { |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
|
-
|
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
|
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,
|
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
|