ask-graph 0.6.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: 67250ca086cc687aff14bda7a2365e1f170eea92c8cd4f88565ec8b678ee8d9c
4
- data.tar.gz: 3450ffce0b35cadd19798757116e2bbc6a82a9460f4686187131061f63ba8663
3
+ metadata.gz: 99d66a3b6b63d97a13187b67bbefd25c291d5ab80a63146e61639a0b7949689d
4
+ data.tar.gz: ab66b1d0586ee8570e71f2612938d6d87a9e3c5a3d44b93b046ef95343a5951a
5
5
  SHA512:
6
- metadata.gz: cbef57c596215023c161198fc5951316777ba9686428fce403ce62e498b24e22edc1d40d9a1eec56ad010c397419ad1069219e16bad22b1768ff516e03df7cff
7
- data.tar.gz: 830f3ea4c53e3db5ef8e26ba1bb22f105bf05e2419dba92bc6a113d1b2ae7fe6f40d3c866fc8cf4429d7f5649717ad9c9f6a1d389e7d0cda49f4a49444b7b8b3
6
+ metadata.gz: 8cd5ca7104c5ea6f643e9f9333ec8a7578b53f0fcda6c0f8a5a4273ac9cbb856025024e192a9f89c4a30fc3c8072b6b2b46283ccdf3805db05faade627fef81c
7
+ data.tar.gz: f8f4e2494be0350b17b86f568c1de1428a1731b73f9599132f6fe408a70fed15649c803847cf01a36ba009b819f2bcfaf3f37df6d8db2a8dd36dface2c8ae01b
data/CHANGELOG.md CHANGED
@@ -1,3 +1,38 @@
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
+
1
36
  ## [0.6.0] — 2026-07-27
2
37
 
3
38
  ### Changed
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
@@ -131,9 +131,10 @@ module Ask
131
131
  # @param graph_class [Class < Ask::Graph] the graph to run
132
132
  # @return [Ask::Graph::Context] the sub-graph's context
133
133
  # @example
134
+ # # Inside a step PORO
134
135
  # class CalculateShipping
135
136
  # def call(context)
136
- # context.run(ShippingWorkflow)
137
+ # context.run(Shipping::Workflow)
137
138
  # end
138
139
  # end
139
140
  def run(graph_class)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  class Graph
5
- VERSION = "0.6.0"
5
+ VERSION = "0.6.1"
6
6
  end
7
7
  end
data/lib/ask/graph.rb CHANGED
@@ -11,28 +11,32 @@ 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
20
  # @example Sub-graph composition
21
- # # Define a workflow
22
- # class NotifyCustomerWorkflow < Ask::Graph
23
- # step SendEmail
24
- # step LogNotification
21
+ # # Define a reusable workflow
22
+ # module NotifyCustomer
23
+ # class Workflow < Ask::Graph
24
+ # step SendEmail
25
+ # step LogNotification
26
+ # end
25
27
  # end
26
28
  #
27
29
  # # Wrap it in a PORO step
28
- # class NotifyCustomer
29
- # def call(context)
30
- # NotifyCustomerWorkflow.call(context)
30
+ # module OrderFulfillment
31
+ # class NotifyCustomer
32
+ # def call(context)
33
+ # NotifyCustomer::Workflow.call(context)
34
+ # end
31
35
  # end
32
36
  # end
33
37
  #
34
38
  # # Compose — every step is a PORO
35
- # class HandleOrder < Ask::Graph
39
+ # class OrderFulfillment::Workflow < Ask::Graph
36
40
  # step ValidatePayment
37
41
  # step NotifyCustomer
38
42
  # step ShipOrder
@@ -65,7 +69,8 @@ module Ask
65
69
  def inherited(subclass)
66
70
  super
67
71
  subclass.instance_variable_set(:@declarations, declarations.dup)
68
- subclass.instance_variable_set(:@lifecycle_hooks, lifecycle_hooks.dup)
72
+ subclass.instance_variable_set(:@lifecycle_hooks,
73
+ lifecycle_hooks.transform_values(&:dup))
69
74
  end
70
75
 
71
76
  # --- Timeout ---
@@ -203,12 +208,12 @@ module Ask
203
208
  # are merged back into the original context automatically.
204
209
  #
205
210
  # @example Standalone — pass data
206
- # MyGraph.call({ user: "alice" })
211
+ # OrderFulfillment::Workflow.call({ user: "alice" })
207
212
  #
208
213
  # @example Inside a step — pass the outer context
209
- # class MyStep
214
+ # class ValidatePayment
210
215
  # def call(context)
211
- # OtherGraph.call(context)
216
+ # PaymentGateway::Workflow.call(context)
212
217
  # end
213
218
  # end
214
219
  def call(input = nil, storage: nil)
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.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto