ask-graph 0.5.0 → 0.6.1

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: 5bcdf4454ef1639db46defba3c2b59377dd0aa719036b27be25b77a7a805fdec
4
- data.tar.gz: eef23d886d551984bbd8f1514e7bdfacc38be0d4b029ec832b1e7c61f2d2036f
3
+ metadata.gz: 99d66a3b6b63d97a13187b67bbefd25c291d5ab80a63146e61639a0b7949689d
4
+ data.tar.gz: ab66b1d0586ee8570e71f2612938d6d87a9e3c5a3d44b93b046ef95343a5951a
5
5
  SHA512:
6
- metadata.gz: bef581ebc5816f79ab0b8d7da98dd9cf53fba824e1e68691e5b39ef8ee0eca3380fa2994dde965ad17db24267f9541b4c537c87fa26a3852ba9fa128afc60237
7
- data.tar.gz: 78d63a0b802ee4050d09a08ab4d6f62a7fa8e71425745ef055d2f02824c6fb92f48c3149fadb275723aefb93b04bf0a04c258246b115c4ad028b5a56179a9188
6
+ metadata.gz: 8cd5ca7104c5ea6f643e9f9333ec8a7578b53f0fcda6c0f8a5a4273ac9cbb856025024e192a9f89c4a30fc3c8072b6b2b46283ccdf3805db05faade627fef81c
7
+ data.tar.gz: f8f4e2494be0350b17b86f568c1de1428a1731b73f9599132f6fe408a70fed15649c803847cf01a36ba009b819f2bcfaf3f37df6d8db2a8dd36dface2c8ae01b
data/CHANGELOG.md CHANGED
@@ -1,56 +1,160 @@
1
+ ## [0.6.1] — 2026-07-27
2
+
3
+ ### Fixed
4
+
5
+ - **Hooks leak between classes** — `inherited` now deep-copies lifecycle
6
+ hook arrays instead of sharing them. Previously, a child class's
7
+ `after_step :foo` would mutate the parent class's hook arrays, causing
8
+ hooks to fire multiple times in sibling classes.
9
+
10
+ ### Changed
11
+
12
+ - **Docs updated** to use the `Module::Workflow` convention throughout.
13
+ Recommended layout:
14
+
15
+ ```
16
+ app/workflows/
17
+ notify_customer/
18
+ workflow.rb # module NotifyCustomer; class Workflow < Ask::Graph
19
+ steps/
20
+ send_email.rb
21
+ log_notification.rb
22
+ order_fulfillment/
23
+ workflow.rb
24
+ steps/
25
+ validate_payment.rb
26
+ notify_customer.rb
27
+ ship_order.rb
28
+ ```
29
+
30
+ Sub-graph composition via `Workflow.call(context)` or `context.run(Workflow)`.
31
+
32
+ ### Tested
33
+
34
+ - 85 tests, 111 assertions, 0 failures
35
+
36
+ ## [0.6.0] — 2026-07-27
37
+
38
+ ### Changed
39
+
40
+ - **All steps must be POROs** — the framework no longer auto-detects
41
+ `Ask::Graph` subclasses used directly as steps. Every `step` declaration
42
+ must be a plain Ruby class with a `call(context)` method. This eliminates
43
+ ambiguity at the `step` call site.
44
+
45
+ ```ruby
46
+ # Before — worked but was ambiguous
47
+ step NotifyCustomer # is this a Graph or a PORO?
48
+
49
+ # After — always a PORO
50
+ step NotifyCustomer # definitely a PORO
51
+ ```
52
+
53
+ - **Sub-graph composition via `Workflow.call(context)`** — compose workflows
54
+ by calling another graph's class method with the current context. The
55
+ method auto-detects a Context argument and handles export → run → import.
56
+
57
+ ```ruby
58
+ class NotifyCustomer
59
+ def call(context)
60
+ NotifyCustomerWorkflow.call(context)
61
+ end
62
+ end
63
+ ```
64
+
65
+ `Graph.call(ctx)` is the same API users already know from controllers:
66
+ `Graph.call(params)`. Just pass a Context instead of data.
67
+
68
+ ### Added
69
+
70
+ - **`Context#run(graph_class)`** — convenience wrapper around the export →
71
+ create → call → import pattern.
72
+
73
+ - **Hash inputs flatten into context keys** — when a Hash is passed as
74
+ input, its keys are directly accessible on the context in addition to
75
+ `context.input`. Enables sub-graphs to transparently read outer values.
76
+
77
+ - **`Context#export_data`** and **`Context#import(other)`** — public API
78
+ for sub-graph data passing.
79
+
80
+ ### Tested
81
+
82
+ - 85 tests, 111 assertions, 0 failures
83
+
1
84
  ## [0.5.0] — 2026-07-27
2
85
 
3
86
  ### Added
4
87
 
5
- - **Sub-graph composition** use any `Ask::Graph` subclass as a step in another
6
- graph. The sub-graph runs all its internal steps and merges its context back
7
- into the outer graph.
88
+ - **Sub-graph composition via `Workflow.call(context)`** compose workflows
89
+ by calling another graph's `call` class method with the current context.
90
+ The method detects the Context argument and handles export → run → import
91
+ automatically.
8
92
 
9
93
  ```ruby
10
- class NotifyCustomer < Ask::Graph
94
+ # Define a workflow
95
+ class NotifyCustomerWorkflow < Ask::Graph
11
96
  step SendEmail
12
97
  step LogNotification
13
98
  end
14
99
 
100
+ # Use it as a step via a PORO wrapper
101
+ class NotifyCustomer
102
+ def call(context)
103
+ NotifyCustomerWorkflow.call(context)
104
+ end
105
+ end
106
+
107
+ # Compose — every step is a PORO
15
108
  class HandleOrder < Ask::Graph
16
109
  step ValidatePayment
17
- step NotifyCustomer # runs the sub-graph, merges its context
110
+ step NotifyCustomer
18
111
  step ShipOrder
19
112
  end
20
113
  ```
21
114
 
22
- - Sub-graphs read the outer graph's context directly no special wiring
23
- - Sub-graph writes are merged back automatically
24
- - Supports nesting (sub-graphs within sub-graphs)
115
+ - `Graph.call(context)` exports the context data, creates the sub-graph,
116
+ runs it, and imports results back — all automatically
117
+ - Same API users already know from `Graph.call(params)` in controllers
118
+ - Supports nesting (sub-graphs calling sub-graphs)
25
119
  - Supports `timeout:`, `retry:`, and `if:`/`unless:` on the outer step
26
120
  - Supports parallel steps inside sub-graphs
27
- - Shares the same `storage` backend with the outer graph
121
+
122
+ - **Hash inputs flatten into context keys** — when a Hash is passed as
123
+ input, its keys are directly accessible on the context in addition to
124
+ `context.input`. This enables sub-graphs to transparently read outer
125
+ context values.
126
+
127
+ ```ruby
128
+ g.new({ value: 42 }).call
129
+ ctx.value # => 42 (directly accessible)
130
+ ctx.input # => { value: 42 } (still works as before)
131
+ ```
28
132
 
29
133
  - **`Context#export_data`** — returns the raw store hash without JSON
30
134
  serialization. Used internally for sub-graph data passing.
31
135
 
32
136
  - **`Context#import(other)`** — merges data from another context or hash
33
- into this one. Used internally by sub-graph composition.
137
+ into this one.
34
138
 
35
- - **Hash inputs now flatten into context keys** when a Hash is passed as
36
- input to a graph, its keys are directly accessible on the context in
37
- addition to `context.input`. This enables sub-graphs to transparently
38
- read outer context values.
139
+ - **`Context#run(graph_class)`**convenience method wrapping the
140
+ export create call import pattern.
39
141
 
40
142
  ```ruby
41
- g.new({ value: 42 }).call
42
- ctx.value # => 42 (new — directly accessible)
43
- ctx.input # => { value: 42 } (still works)
143
+ def call(context)
144
+ context.run(NotifyCustomerWorkflow)
145
+ end
44
146
  ```
45
147
 
46
- ### Limitations
148
+ ### Changed
47
149
 
48
- - Sub-graphs containing `approve` steps are not yet supported and raise
49
- `ArgumentError`. Nested approval will be added in a future release.
150
+ - **All steps are POROs** the framework no longer detects `Ask::Graph`
151
+ subclasses used directly as steps. Every `step` declaration must be a
152
+ plain Ruby class with a `call(context)` method. This eliminates the
153
+ ambiguity of "is this step a graph or a PORO?"
50
154
 
51
155
  ### Tested
52
156
 
53
- - 87 tests, 114 assertions, 0 failures
157
+ - 85 tests, 111 assertions, 0 failures
54
158
 
55
159
  ## [0.4.0] — 2026-07-27
56
160
 
data/README.md CHANGED
@@ -6,28 +6,30 @@
6
6
  gem "ask-graph"
7
7
  ```
8
8
 
9
- ## Quick Start
10
-
11
- ```ruby
12
- require "ask-graph"
13
-
14
- # Define a workflow
15
- class ProcessOrder < Ask::Graph
16
- step ValidateOrder
17
- step ChargeCustomer, if: :valid?
18
- step SendConfirmation, if: :valid?
19
- step NotifyAdmin, unless: :valid?
20
-
21
- private
22
-
23
- def valid?
24
- context.order.valid?
25
- end
26
- end
27
-
28
- # Run it
29
- result = ProcessOrder.run(order: order)
30
- ```
9
+ ## Quick Start
10
+
11
+ ```ruby
12
+ require "ask-graph"
13
+
14
+ # Define a workflow
15
+ module ProcessOrder
16
+ class Workflow < Ask::Graph
17
+ step ValidateOrder
18
+ step ChargeCustomer, if: :valid?
19
+ step SendConfirmation, if: :valid?
20
+ step NotifyAdmin, unless: :valid?
21
+
22
+ private
23
+
24
+ def valid?
25
+ context.order.valid?
26
+ end
27
+ end
28
+ end
29
+
30
+ # Run it
31
+ result = ProcessOrder::Workflow.call(order: order)
32
+ ```
31
33
 
32
34
  ## Steps
33
35
 
@@ -41,86 +43,96 @@ class ValidateOrder
41
43
  end
42
44
  ```
43
45
 
44
- ### Sequential steps
45
-
46
- ```ruby
47
- class HandleCall < Ask::Graph
48
- step Transcribe
49
- step Classify
50
- step Respond
51
- end
52
- ```
53
-
54
- ### Conditional steps
55
-
56
- ```ruby
57
- class HandleCall < Ask::Graph
58
- step BookAppointment, if: :booking?
59
- step EmergencyAlert, if: :emergency?
60
- step HandleInquiry, unless: :known_intent?
61
-
62
- private
63
-
64
- def booking? = context.intent == "booking"
65
- def emergency? = context.intent == "emergency"
66
- def known_intent? = %w[booking emergency inquiry].include?(context.intent)
67
- end
68
- ```
69
-
70
- ### Parallel steps
71
-
72
- Use `steps` (plural) to run multiple steps simultaneously:
73
-
74
- ```ruby
75
- class SyncData < Ask::Graph
76
- step FetchRecords
77
-
78
- # All three run in parallel
79
- steps CrmUpdate, CalendarSync, SendNotification
80
-
81
- step ConfirmResponse
82
- end
83
- ```
84
-
85
- ### Human-in-the-loop (approve)
86
-
87
- `approve` runs a step, then pauses the workflow and waits for external input:
88
-
89
- ```ruby
90
- class ProcessBooking < Ask::Graph
91
- step BookAppointment
92
- approve ReviewBooking, if: :expensive?
93
- step ConfirmBooking
94
- end
95
- ```
96
-
97
- After `approve` pauses, resume with:
98
-
99
- ```ruby
100
- graph = ProcessBooking.new
101
- result = graph.run # runs, pauses after ReviewBooking
102
- result = graph.resume(input: "approved") # resumes, runs ConfirmBooking
103
- ```
104
-
105
- ### Per-item loops
106
-
107
- Use `context.each` inside a step for iteration with automatic checkpointing:
108
-
109
- ```ruby
110
- class SendVoiceReminders
111
- def call(context)
112
- context.each(context.appointments) do |appt|
113
- PhoneService.call(appt.number, appt.message)
114
- end
115
- end
116
- end
117
-
118
- class DailyReminders < Ask::Graph
119
- step FetchAppointments
120
- step SendVoiceReminders # loops with per-item checkpointing
121
- step MarkComplete
122
- end
123
- ```
46
+ ### Sequential steps
47
+
48
+ ```ruby
49
+ module HandleCall
50
+ class Workflow < Ask::Graph
51
+ step Transcribe
52
+ step Classify
53
+ step Respond
54
+ end
55
+ end
56
+ ```
57
+
58
+ ### Conditional steps
59
+
60
+ ```ruby
61
+ module HandleCall
62
+ class Workflow < Ask::Graph
63
+ step BookAppointment, if: :booking?
64
+ step EmergencyAlert, if: :emergency?
65
+ step HandleInquiry, unless: :known_intent?
66
+
67
+ private
68
+
69
+ def booking? = context.intent == "booking"
70
+ def emergency? = context.intent == "emergency"
71
+ def known_intent? = %w[booking emergency inquiry].include?(context.intent)
72
+ end
73
+ end
74
+ ```
75
+
76
+ ### Parallel steps
77
+
78
+ Use `steps` (plural) to run multiple steps simultaneously:
79
+
80
+ ```ruby
81
+ module SyncData
82
+ class Workflow < Ask::Graph
83
+ step FetchRecords
84
+
85
+ # All three run in parallel
86
+ steps CrmUpdate, CalendarSync, SendNotification
87
+
88
+ step ConfirmResponse
89
+ end
90
+ end
91
+ ```
92
+
93
+ ### Human-in-the-loop (approve)
94
+
95
+ `approve` runs a step, then pauses the workflow and waits for external input:
96
+
97
+ ```ruby
98
+ module ProcessBooking
99
+ class Workflow < Ask::Graph
100
+ step BookAppointment
101
+ approve ReviewBooking, if: :expensive?
102
+ step ConfirmBooking
103
+ end
104
+ end
105
+ ```
106
+
107
+ After `approve` pauses, resume with:
108
+
109
+ ```ruby
110
+ graph = ProcessBooking::Workflow.new
111
+ result = graph.run # runs, pauses after ReviewBooking
112
+ result = graph.resume(input: "approved") # resumes, runs ConfirmBooking
113
+ ```
114
+
115
+ ### Per-item loops
116
+
117
+ Use `context.each` inside a step for iteration with automatic checkpointing:
118
+
119
+ ```ruby
120
+ class SendVoiceReminders
121
+ def call(context)
122
+ context.each(context.appointments) do |appt|
123
+ PhoneService.call(appt.number, appt.message)
124
+ end
125
+ end
126
+ end
127
+
128
+ module DailyReminders
129
+ class Workflow < Ask::Graph
130
+ step FetchAppointments
131
+ step SendVoiceReminders # loops with per-item checkpointing
132
+ step MarkComplete
133
+ end
134
+ end
135
+ ```
124
136
 
125
137
  If the process crashes mid-loop, it resumes from the last completed item.
126
138
 
@@ -146,33 +158,48 @@ end
146
158
 
147
159
  ### Sub-workflows
148
160
 
149
- A step can delegate to another workflow:
161
+ A step can delegate to another workflow by calling `Workflow.call(context)`.
162
+ The context is automatically exported, passed to the sub-workflow, and
163
+ results are merged back on completion:
150
164
 
151
165
  ```ruby
152
- class IntentHandler
153
- def call(context)
154
- case context.intent
155
- when "booking"
156
- BookingWorkflow.new.call(context) # sub-workflow as a step
157
- when "emergency"
158
- EmergencyWorkflow.new.call(context)
166
+ module OrderFulfillment
167
+ class NotifyCustomer
168
+ def call(context)
169
+ NotifyCustomer::Workflow.call(context)
159
170
  end
160
171
  end
172
+
173
+ class Workflow < Ask::Graph
174
+ step ValidatePayment
175
+ step NotifyCustomer
176
+ step ShipOrder
177
+ end
178
+ end
179
+ ```
180
+
181
+ Or use `context.run` for the same thing:
182
+
183
+ ```ruby
184
+ class NotifyCustomer
185
+ def call(context)
186
+ context.run(NotifyCustomer::Workflow)
187
+ end
161
188
  end
162
189
  ```
163
190
 
164
191
  ### Checkpointing
165
192
 
166
- Pass a checkpoint store to make workflows durable across crashes:
193
+ Pass a `storage` backend to make workflows durable across crashes:
167
194
 
168
195
  ```ruby
169
196
  store = Ask::State::Memory.new # or Redis, SQLite, etc.
170
197
 
171
198
  # Runs through all steps, saving after each
172
- result = MyWorkflow.run(input, checkpoint_store: store)
199
+ result = OrderFulfillment::Workflow.call(input, storage: store)
173
200
 
174
201
  # If a crash occurs, resume from the last completed step
175
- result = MyWorkflow.run(input, checkpoint_store: store)
202
+ result = OrderFulfillment::Workflow.call(input, storage: store)
176
203
  ```
177
204
 
178
205
  ### Error handling
@@ -180,12 +207,14 @@ result = MyWorkflow.run(input, checkpoint_store: store)
180
207
  Failed steps raise `Ask::Graph::StepFailed` with the step name:
181
208
 
182
209
  ```ruby
183
- class MyGraph < Ask::Graph
184
- step RiskyOperation
210
+ module MyWorkflow
211
+ class Workflow < Ask::Graph
212
+ step RiskyOperation
213
+ end
185
214
  end
186
215
 
187
216
  begin
188
- MyGraph.run
217
+ MyWorkflow::Workflow.call
189
218
  rescue Ask::Graph::StepFailed => e
190
219
  puts e.message # => "RiskyOperation failed: connection timeout"
191
220
  end
@@ -123,6 +123,27 @@ module Ask
123
123
  @mutex.synchronize { @store.merge!(data) }
124
124
  end
125
125
 
126
+ # Run an {Ask::Graph} as a sub-graph, merging its context back.
127
+ #
128
+ # Exports the current context data, creates the sub-graph with it,
129
+ # runs it, and merges any results back into this context.
130
+ #
131
+ # @param graph_class [Class < Ask::Graph] the graph to run
132
+ # @return [Ask::Graph::Context] the sub-graph's context
133
+ # @example
134
+ # # Inside a step PORO
135
+ # class CalculateShipping
136
+ # def call(context)
137
+ # context.run(Shipping::Workflow)
138
+ # end
139
+ # end
140
+ def run(graph_class)
141
+ sub = graph_class.new(export_data)
142
+ sub_ctx = sub.call
143
+ import(sub_ctx)
144
+ sub_ctx
145
+ end
146
+
126
147
  private
127
148
 
128
149
  # Recursively convert a value to a JSON-safe structure.
@@ -133,29 +133,14 @@ module Ask
133
133
  end
134
134
 
135
135
  def run_single(klass, _name, context)
136
- if klass < Ask::Graph
137
- run_subgraph(klass, context)
138
- else
139
- instance = klass.new
140
- instance.call(context)
141
- end
142
- rescue Paused, ArgumentError
136
+ instance = klass.new
137
+ instance.call(context)
138
+ rescue Paused
143
139
  raise
144
140
  rescue => e
145
141
  raise StepFailed, "#{klass.name} failed: #{e.message}"
146
142
  end
147
143
 
148
- def run_subgraph(klass, context)
149
- if klass.respond_to?(:declarations) && klass.declarations.any? { |d| d[:type] == :approve }
150
- raise ArgumentError,
151
- "#{klass.name} contains approve steps — sub-graphs with approve are not yet supported"
152
- end
153
-
154
- sub = klass.new(context.export_data, storage: @store)
155
- sub_ctx = sub.call
156
- context.import(sub_ctx) if sub_ctx
157
- end
158
-
159
144
  def run_parallel(classes, _name, context)
160
145
  threads = classes.map do |klass|
161
146
  Thread.new do
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  class Graph
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.1"
6
6
  end
7
7
  end
data/lib/ask/graph.rb CHANGED
@@ -11,21 +11,34 @@ module Ask
11
11
  # lifecycle hooks.
12
12
  #
13
13
  # @example Basic workflow
14
- # class HandleCall < Ask::Graph
14
+ # class HandleCall::Workflow < Ask::Graph
15
15
  # step Transcribe
16
16
  # step Classify, if: :needs_classification?
17
17
  # step BookAppointment
18
18
  # end
19
19
  #
20
- # @example Sub-graph composition (reuse a graph as a step)
21
- # class NotifyCustomer < Ask::Graph
22
- # step SendEmail
23
- # step LogNotification
20
+ # @example Sub-graph composition
21
+ # # Define a reusable workflow
22
+ # module NotifyCustomer
23
+ # class Workflow < Ask::Graph
24
+ # step SendEmail
25
+ # step LogNotification
26
+ # end
24
27
  # end
25
28
  #
26
- # class HandleOrder < Ask::Graph
29
+ # # Wrap it in a PORO step
30
+ # module OrderFulfillment
31
+ # class NotifyCustomer
32
+ # def call(context)
33
+ # NotifyCustomer::Workflow.call(context)
34
+ # end
35
+ # end
36
+ # end
37
+ #
38
+ # # Compose — every step is a PORO
39
+ # class OrderFulfillment::Workflow < Ask::Graph
27
40
  # step ValidatePayment
28
- # step NotifyCustomer # runs the sub-graph, merges its context
41
+ # step NotifyCustomer
29
42
  # step ShipOrder
30
43
  # end
31
44
  #
@@ -56,7 +69,8 @@ module Ask
56
69
  def inherited(subclass)
57
70
  super
58
71
  subclass.instance_variable_set(:@declarations, declarations.dup)
59
- subclass.instance_variable_set(:@lifecycle_hooks, lifecycle_hooks.dup)
72
+ subclass.instance_variable_set(:@lifecycle_hooks,
73
+ lifecycle_hooks.transform_values(&:dup))
60
74
  end
61
75
 
62
76
  # --- Timeout ---
@@ -187,9 +201,29 @@ module Ask
187
201
  declarations << build_declaration(:approve, klass, opts)
188
202
  end
189
203
 
190
- # Convenience: create instance and call.
204
+ # Create an instance and run it.
205
+ #
206
+ # When +input+ is a {Context}, the graph is run as a sub-graph:
207
+ # the context data is exported, the graph runs with it, and results
208
+ # are merged back into the original context automatically.
209
+ #
210
+ # @example Standalone — pass data
211
+ # OrderFulfillment::Workflow.call({ user: "alice" })
212
+ #
213
+ # @example Inside a step — pass the outer context
214
+ # class ValidatePayment
215
+ # def call(context)
216
+ # PaymentGateway::Workflow.call(context)
217
+ # end
218
+ # end
191
219
  def call(input = nil, storage: nil)
192
- new(input, storage: storage).call
220
+ if input.is_a?(Context)
221
+ ctx = new(input.export_data, storage: storage).call
222
+ input.import(ctx)
223
+ ctx
224
+ else
225
+ new(input, storage: storage).call
226
+ end
193
227
  end
194
228
  alias run call
195
229
 
@@ -224,7 +258,7 @@ module Ask
224
258
  attr_reader :runner
225
259
  attr_reader :context
226
260
 
227
- def call
261
+ def call(*)
228
262
  @context = Context.new(self, @input)
229
263
  @runner.run(@context)
230
264
  @context
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-graph
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto