operational 0.1.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 +7 -0
- data/AI_README.md +328 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +668 -0
- data/lib/operational/controller.rb +26 -0
- data/lib/operational/error.rb +7 -0
- data/lib/operational/form.rb +72 -0
- data/lib/operational/operation/contract.rb +42 -0
- data/lib/operational/operation/nested.rb +13 -0
- data/lib/operational/operation.rb +66 -0
- data/lib/operational/result.rb +29 -0
- data/lib/operational/version.rb +3 -0
- data/lib/operational.rb +14 -0
- metadata +115 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7eb527cb1be0cf712722402acc06648901bc052c16e5eda7694b17daa3aa707b
|
|
4
|
+
data.tar.gz: 834d9cc329f5679c536bbd7e45acc4bf4926a782e22ac5738168f729779a59de
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 697b98acdd2a9d2ed1ec6e812fc29989dafd00df0b9f49dc6a72c26458ac7ea89ff8338ed3aaaecc8d6dc2694ad873e1b9c1632e973b3be1b1100580835b495e
|
|
7
|
+
data.tar.gz: 5e3d5ab2d70b564912ca8f7d05d8f2fe2347971e570954c8f34442de1749469f3fe5fe23d0f65eb599bb1ecec75737eb672c08b84d2ad58c30f824edf1213d5a
|
data/AI_README.md
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# Operational — AI Agent Reference
|
|
2
|
+
|
|
3
|
+
This document is for AI coding agents. It describes the exact API of the `operational` gem so you can generate correct code without guessing.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
Operational has four components:
|
|
8
|
+
|
|
9
|
+
1. **Operation** — orchestrates a business process as a railway of steps
|
|
10
|
+
2. **Form** — validates and transforms user input, decoupled from models (built on ActiveModel)
|
|
11
|
+
3. **Contract** — step helpers that wire forms into operations (Build → Validate → Sync)
|
|
12
|
+
4. **Controller** — Rails mixin that runs operations from controller actions
|
|
13
|
+
|
|
14
|
+
## Operations
|
|
15
|
+
|
|
16
|
+
Subclass `Operational::Operation`. Define steps with `step`, `pass`, or `fail` at the class level. Call with `.call(state_hash)`. Returns an `Operational::Result`.
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
class CreateArticleOperation < Operational::Operation
|
|
20
|
+
step :init
|
|
21
|
+
step Contract::Build(contract: ArticleForm)
|
|
22
|
+
step Contract::Validate()
|
|
23
|
+
step Contract::Sync()
|
|
24
|
+
step :save
|
|
25
|
+
pass :notify # return value ignored, never derails
|
|
26
|
+
fail :handle # only runs on failure track
|
|
27
|
+
|
|
28
|
+
def init(state)
|
|
29
|
+
state[:model] = Article.new
|
|
30
|
+
# must return truthy to continue, falsy switches to failure track
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def save(state)
|
|
34
|
+
state[:model].save # returns true/false naturally
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def notify(state)
|
|
38
|
+
# side effect, return value doesn't matter
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def handle(state)
|
|
42
|
+
# runs on failure track
|
|
43
|
+
# return truthy to recover back to success track
|
|
44
|
+
# return falsy to stay on failure track
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Step types
|
|
51
|
+
|
|
52
|
+
| Type | Runs when | Truthy return | Falsy return |
|
|
53
|
+
|--------|------------------|-----------------------|-------------------------|
|
|
54
|
+
| `step` | On success track | Continue success | Switch to failure track |
|
|
55
|
+
| `fail` | On failure track | Recover to success | Continue failure |
|
|
56
|
+
| `pass` | On success track | Continue success | Continue success |
|
|
57
|
+
|
|
58
|
+
### Step actions
|
|
59
|
+
|
|
60
|
+
A step action can be:
|
|
61
|
+
- **Symbol** — calls instance method with `(state)` argument
|
|
62
|
+
- **Lambda/Proc** — called with `(state)` argument
|
|
63
|
+
- **Any object responding to `.call`** — called with `(state)` argument
|
|
64
|
+
|
|
65
|
+
### State
|
|
66
|
+
|
|
67
|
+
State is a plain Ruby hash passed to `.call`. It is mutable — steps read from and write to it. The result's state is a frozen duplicate.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
result = MyOperation.call(user: user, params: params_hash)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Result
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
result.succeeded? # => true/false
|
|
77
|
+
result.failed? # => true/false
|
|
78
|
+
result.state # => frozen hash
|
|
79
|
+
result[:key] # => shorthand for result.state[:key]
|
|
80
|
+
result.operation # => the operation instance
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Nested operations
|
|
84
|
+
|
|
85
|
+
Use `Nested::Operation` to call one operation from within another. State is merged back. The nested result's `succeeded?` determines if the parent continues on success or failure track.
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
class CreateArticleOperation < Operational::Operation
|
|
89
|
+
class Present < Operational::Operation
|
|
90
|
+
step :init
|
|
91
|
+
step Contract::Build(contract: ArticleForm)
|
|
92
|
+
|
|
93
|
+
def init(state)
|
|
94
|
+
state[:model] = Article.new
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
step Nested::Operation(operation: Present)
|
|
99
|
+
step Contract::Validate()
|
|
100
|
+
step Contract::Sync()
|
|
101
|
+
pass :persist
|
|
102
|
+
|
|
103
|
+
def persist(state)
|
|
104
|
+
state[:model].save!
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Forms
|
|
110
|
+
|
|
111
|
+
Subclass `Operational::Form`. Uses `ActiveModel::Model`, `ActiveModel::Attributes`, `ActiveModel::Dirty`.
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
class ArticleForm < Operational::Form
|
|
115
|
+
attribute :title, :string
|
|
116
|
+
attribute :body, :string
|
|
117
|
+
|
|
118
|
+
validates :title, presence: true
|
|
119
|
+
validates :body, presence: true
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Form.build
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
Form.build(
|
|
127
|
+
model: nil, # ActiveModel instance — copies matching attributes to form
|
|
128
|
+
model_persisted: nil, # override persisted? detection (true/false/nil)
|
|
129
|
+
state: {}, # context hash, available as @state in the form
|
|
130
|
+
build_method: :on_build # method to call during build
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- Only attributes defined on the form are copied from the model (nil values are skipped)
|
|
135
|
+
- State is frozen and stored as `@state`
|
|
136
|
+
- If the form defines `on_build(state)`, it is called during build after attribute assignment
|
|
137
|
+
- `changes_applied` is called after build so dirty tracking starts clean
|
|
138
|
+
|
|
139
|
+
### Form.validate
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
form.validate(params_hash) # => true/false
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- Converts `ActionController::Parameters` automatically via `to_unsafe_h`
|
|
146
|
+
- Only assigns params matching defined attributes (ignores unknown keys)
|
|
147
|
+
- Calls `valid?` and returns the result
|
|
148
|
+
|
|
149
|
+
### Form.sync
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
form.sync(
|
|
153
|
+
model: nil, # ActiveModel instance — copies matching attributes back
|
|
154
|
+
state: {}, # passed to on_sync
|
|
155
|
+
sync_method: :on_sync # custom hook method name
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- Copies form attributes to model where attribute names match
|
|
160
|
+
- Calls `on_sync(state)` if defined on the form
|
|
161
|
+
- Always returns `true`
|
|
162
|
+
|
|
163
|
+
### Important: do NOT define `#sync` on a form subclass
|
|
164
|
+
|
|
165
|
+
Defining `#sync` raises `MethodCollision`. Use `#on_sync` instead — it is called automatically during sync.
|
|
166
|
+
|
|
167
|
+
### Helper methods
|
|
168
|
+
|
|
169
|
+
- `persisted?` — returns whether the model was persisted at build time
|
|
170
|
+
- `other_validators_have_passed?` — returns `errors.blank?`, useful for conditional validators
|
|
171
|
+
- `@state` — access the frozen state hash passed at build time
|
|
172
|
+
|
|
173
|
+
## Contract step helpers
|
|
174
|
+
|
|
175
|
+
These are used inside operations as step actions. They return lambdas.
|
|
176
|
+
|
|
177
|
+
### Contract::Build
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
step Contract::Build(
|
|
181
|
+
contract: MyForm, # required — the form class
|
|
182
|
+
name: :contract, # state key to store the form instance
|
|
183
|
+
model_key: :model, # state key containing the model to build from (used only if present in state)
|
|
184
|
+
model_persisted: nil, # override persisted? detection
|
|
185
|
+
build_method: :on_build
|
|
186
|
+
)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Always returns `true`.
|
|
190
|
+
|
|
191
|
+
### Contract::Validate
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
step Contract::Validate(
|
|
195
|
+
name: :contract, # state key where the form is stored
|
|
196
|
+
params_path: nil # nil → state[:params]
|
|
197
|
+
# :symbol → state[:params][:symbol]
|
|
198
|
+
# [:a, :b] → state.dig(:a, :b)
|
|
199
|
+
)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Returns the result of `form.validate(params)` — `true`/`false`.
|
|
203
|
+
|
|
204
|
+
### Contract::Sync
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
step Contract::Sync(
|
|
208
|
+
name: :contract, # state key where the form is stored
|
|
209
|
+
model_key: :model, # state key containing the model to sync to
|
|
210
|
+
sync_method: :on_sync # custom sync hook method name
|
|
211
|
+
)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Returns `true` (from `form.sync`).
|
|
215
|
+
|
|
216
|
+
## Controller mixin
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
class MyController < ApplicationController
|
|
220
|
+
include Operational::Controller
|
|
221
|
+
|
|
222
|
+
def create
|
|
223
|
+
if run CreateArticleOperation
|
|
224
|
+
redirect_to @state[:model]
|
|
225
|
+
else
|
|
226
|
+
render :new, status: :unprocessable_entity
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### run(operation, **extras)
|
|
233
|
+
|
|
234
|
+
- Merges `extras` with default state (`params` and `current_user` if available)
|
|
235
|
+
- Calls `operation.call(state)`
|
|
236
|
+
- Sets `@state` to the frozen result state
|
|
237
|
+
- Returns `result.succeeded?`
|
|
238
|
+
|
|
239
|
+
### Overridable methods
|
|
240
|
+
|
|
241
|
+
- `_operational_default_state` — override to inject custom default state
|
|
242
|
+
- `_operational_state_variable` — override to change the instance variable name (default: `@state`)
|
|
243
|
+
|
|
244
|
+
## Errors
|
|
245
|
+
|
|
246
|
+
| Error class | Raised when |
|
|
247
|
+
|---|---|
|
|
248
|
+
| `Operational::InvalidContractModel` | Model doesn't respond to `attributes` |
|
|
249
|
+
| `Operational::UnknownStepType` | Step action is not a Symbol or callable |
|
|
250
|
+
| `Operational::MethodCollision` | Form subclass defines `#sync` instead of `#on_sync` |
|
|
251
|
+
|
|
252
|
+
## File structure convention
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
app/concepts/<domain>/
|
|
256
|
+
<name>_form.rb
|
|
257
|
+
<name>_operation.rb
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Example: `app/concepts/article/article_form.rb`, `app/concepts/article/create_article_operation.rb`
|
|
261
|
+
|
|
262
|
+
## Common patterns
|
|
263
|
+
|
|
264
|
+
### New/Create with nested Present
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
class CreateThingOperation < Operational::Operation
|
|
268
|
+
class Present < Operational::Operation
|
|
269
|
+
step :init
|
|
270
|
+
step Contract::Build(contract: ThingForm)
|
|
271
|
+
|
|
272
|
+
def init(state)
|
|
273
|
+
state[:model] = Thing.new
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
step Nested::Operation(operation: Present)
|
|
278
|
+
step Contract::Validate()
|
|
279
|
+
step Contract::Sync()
|
|
280
|
+
pass :persist
|
|
281
|
+
|
|
282
|
+
def persist(state)
|
|
283
|
+
state[:model].save!
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Controller uses `CreateThingOperation::Present` for `new` and `CreateThingOperation` for `create`.
|
|
289
|
+
|
|
290
|
+
To use a descriptive state key instead of `:model`, pass `model_key:` explicitly:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
step Contract::Build(contract: ThingForm, model_key: :thing)
|
|
294
|
+
step Contract::Sync(model_key: :thing)
|
|
295
|
+
# state[:thing] instead of state[:model]
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### Multi-model form
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
class OrderForm < Operational::Form
|
|
302
|
+
attribute :item_name, :string
|
|
303
|
+
attribute :shipping_address, :string
|
|
304
|
+
|
|
305
|
+
def on_build(state)
|
|
306
|
+
self.shipping_address = state[:user]&.default_address
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def on_sync(state)
|
|
310
|
+
state[:shipping].update!(address: shipping_address)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### State-dependent validation
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
class ArticleForm < Operational::Form
|
|
319
|
+
attribute :published, :boolean
|
|
320
|
+
validate :admin_only_publish
|
|
321
|
+
|
|
322
|
+
def admin_only_publish
|
|
323
|
+
if published && !@state[:current_user]&.admin?
|
|
324
|
+
errors.add(:published, "requires admin privileges")
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
```
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Bryan Rite
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|