ask-graph 0.7.1 → 0.7.2
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/CHANGELOG.md +6 -0
- data/README.md +70 -177
- data/lib/ask/graph/runner.rb +8 -0
- data/lib/ask/graph/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d76192a260425897adec826a504bcdf81b773abfd1d5d479945ad9e39dfa2387
|
|
4
|
+
data.tar.gz: d368fbc3f35957b443ab9f5795824b8715633c0ea77ca74484aeb8f9f96e3a6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e963722c00c2fb40bcdaf05d9686a2aea00178c9847db2e1f6feaaf44f17c2917def642315846b8043f6826dab6e0e9c21f27951c8ad09afbaaa1961e5de0a89
|
|
7
|
+
data.tar.gz: c4751c4291f6f07c3cca869dbf368526701a8de74a0c2bfc358e5d3c4d37d75103e8b4d8a2581ffd2ff068760503f32c7bf97c29e7912acc047677b58d18cdf3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.7.2] - 2026-08-04
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- **Parallel steps inherit the caller's thread-local state.** `Runner#run_parallel` now copies `Thread.current` locals into each worker thread, so Rails `CurrentAttributes` and similar per-request context reach parallel steps instead of being nil.
|
|
6
|
+
|
|
1
7
|
## [0.7.1] — 2026-07-31
|
|
2
8
|
|
|
3
9
|
### Added
|
data/README.md
CHANGED
|
@@ -1,35 +1,42 @@
|
|
|
1
1
|
# ask-graph
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Durable workflow graphs for the ask-rb ecosystem. Define workflows as graphs
|
|
4
|
+
of named steps with conditional routing, parallel execution, human-in-the-loop
|
|
5
|
+
approval, per-item checkpointing loops, and sub-graph composition. Each step
|
|
6
|
+
is a plain Ruby class with a `call(context)` method.
|
|
7
|
+
|
|
8
|
+
ask-graph is for deterministic pipelines: you declare the steps and the
|
|
9
|
+
routing. For LLM-driven loops that decide their own next step, use
|
|
10
|
+
ask-agent instead.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
4
13
|
|
|
5
14
|
```ruby
|
|
6
15
|
gem "ask-graph"
|
|
7
16
|
```
|
|
8
17
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
result = ProcessOrder::Workflow.call(order: order)
|
|
32
|
-
```
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
require "ask-graph"
|
|
22
|
+
|
|
23
|
+
module ProcessOrder
|
|
24
|
+
class Workflow < Ask::Graph
|
|
25
|
+
step ValidateOrder
|
|
26
|
+
step ChargeCustomer, if: :valid?
|
|
27
|
+
step SendConfirmation, if: :valid?
|
|
28
|
+
step NotifyAdmin, unless: :valid?
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def valid?
|
|
33
|
+
context.order.valid?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
result = ProcessOrder::Workflow.call(order: order)
|
|
39
|
+
```
|
|
33
40
|
|
|
34
41
|
## Steps
|
|
35
42
|
|
|
@@ -43,188 +50,74 @@ class ValidateOrder
|
|
|
43
50
|
end
|
|
44
51
|
```
|
|
45
52
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
```
|
|
53
|
+
| Declaration | Behavior |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `step Klass` | Run a step in order |
|
|
56
|
+
| `step Klass, if: :method?` / `unless: :method?` | Conditional routing |
|
|
57
|
+
| `step Klass, timeout: 30, retry: 3` | Per-step timeout and retry |
|
|
58
|
+
| `step Klass, description: "..."` | Human-readable label |
|
|
59
|
+
| `steps A, B, C` | Run multiple steps in parallel |
|
|
60
|
+
| `approve Klass` | Run the step, then pause and wait for external input |
|
|
136
61
|
|
|
137
|
-
|
|
62
|
+
A paused workflow resumes with `graph.resume(input: "approved")` after
|
|
63
|
+
`graph.run`.
|
|
138
64
|
|
|
139
65
|
### Context
|
|
140
66
|
|
|
141
|
-
Shared state flows between steps
|
|
67
|
+
Shared state flows between steps. Set and read values by method or bracket,
|
|
68
|
+
iterate with per-item checkpointing, and run sub-workflows:
|
|
142
69
|
|
|
143
70
|
```ruby
|
|
144
|
-
class
|
|
71
|
+
class SendVoiceReminders
|
|
145
72
|
def call(context)
|
|
146
|
-
context.greeting = "hello"
|
|
147
|
-
context[:color] = "blue"
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
class ReadValue
|
|
152
|
-
def call(context)
|
|
153
|
-
puts context.greeting # => "hello"
|
|
154
|
-
puts context[:color] # => "blue"
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
### Sub-workflows
|
|
73
|
+
context.greeting = "hello" # method access
|
|
74
|
+
context[:color] = "blue" # bracket access
|
|
160
75
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
results are merged back on completion:
|
|
164
|
-
|
|
165
|
-
```ruby
|
|
166
|
-
module OrderFulfillment
|
|
167
|
-
class NotifyCustomer
|
|
168
|
-
def call(context)
|
|
169
|
-
NotifyCustomer::Workflow.call(context)
|
|
76
|
+
context.each(context.appointments) do |appt| # per-item checkpointing
|
|
77
|
+
PhoneService.call(appt.number, appt.message)
|
|
170
78
|
end
|
|
171
|
-
end
|
|
172
79
|
|
|
173
|
-
|
|
174
|
-
step ValidatePayment
|
|
175
|
-
step NotifyCustomer
|
|
176
|
-
step ShipOrder
|
|
80
|
+
context.run(NotifyCustomer::Workflow) # sub-workflow
|
|
177
81
|
end
|
|
178
82
|
end
|
|
179
83
|
```
|
|
180
84
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
```ruby
|
|
184
|
-
class NotifyCustomer
|
|
185
|
-
def call(context)
|
|
186
|
-
context.run(NotifyCustomer::Workflow)
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
```
|
|
85
|
+
If the process crashes mid-loop, it resumes from the last completed item.
|
|
190
86
|
|
|
191
|
-
|
|
87
|
+
## Storage and Checkpointing
|
|
192
88
|
|
|
193
|
-
Pass a
|
|
89
|
+
Pass a storage backend to make workflows durable across crashes. The default
|
|
90
|
+
is `Ask::State::Memory`; any backend from ask-state-providers works:
|
|
194
91
|
|
|
195
92
|
```ruby
|
|
196
93
|
store = Ask::State::Memory.new # or Redis, SQLite, etc.
|
|
197
94
|
|
|
198
|
-
# Runs through all steps, saving after each
|
|
199
|
-
result = OrderFulfillment::Workflow.call(input, storage: store)
|
|
200
|
-
|
|
201
|
-
# If a crash occurs, resume from the last completed step
|
|
95
|
+
# Runs through all steps, saving after each; a later call resumes
|
|
202
96
|
result = OrderFulfillment::Workflow.call(input, storage: store)
|
|
203
97
|
```
|
|
204
98
|
|
|
205
|
-
|
|
99
|
+
`Ask::Graph.storage(store)` sets a class-level default for the workflow.
|
|
206
100
|
|
|
207
|
-
|
|
101
|
+
## Errors and Hooks
|
|
208
102
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
class Workflow < Ask::Graph
|
|
212
|
-
step RiskyOperation
|
|
213
|
-
end
|
|
214
|
-
end
|
|
103
|
+
Failed steps raise `Ask::Graph::StepFailed`; timeouts raise `StepTimeout`,
|
|
104
|
+
`WorkflowTimeout`, and approval pauses raise `Paused`.
|
|
215
105
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
106
|
+
Declare lifecycle hooks with `before_step :method`, `after_step :method`, and
|
|
107
|
+
`on_failure :method`. Set timeouts at the class level with `step_timeout` (or
|
|
108
|
+
its alias `default_step_timeout`) and `workflow_timeout`.
|
|
109
|
+
|
|
110
|
+
## Full documentation
|
|
111
|
+
|
|
112
|
+
The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs.
|
|
113
|
+
https://ask-rb.github.io/ask-docs/core/graph covers ask-graph in depth,
|
|
114
|
+
including crash recovery, inheritance, and the full configuration reference.
|
|
115
|
+
API reference: https://ask-rb.github.io/ask-docs/reference/api.
|
|
222
116
|
|
|
223
117
|
## Development
|
|
224
118
|
|
|
225
|
-
|
|
119
|
+
bundle install
|
|
226
120
|
bundle exec rake test
|
|
227
|
-
```
|
|
228
121
|
|
|
229
122
|
## License
|
|
230
123
|
|
data/lib/ask/graph/runner.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "timeout"
|
|
5
|
+
require "time"
|
|
5
6
|
|
|
6
7
|
module Ask
|
|
7
8
|
class Graph
|
|
@@ -158,8 +159,15 @@ module Ask
|
|
|
158
159
|
end
|
|
159
160
|
|
|
160
161
|
def run_parallel(classes, _name, context)
|
|
162
|
+
# Inherit the caller's thread-local state (Rails CurrentAttributes
|
|
163
|
+
# and similar frameworks store per-request context in Thread.current)
|
|
164
|
+
# so parallel steps see the same context as sequential ones.
|
|
165
|
+
inherited_locals = {}
|
|
166
|
+
Thread.current.keys.each { |key| inherited_locals[key] = Thread.current[key] }
|
|
167
|
+
|
|
161
168
|
threads = classes.map do |klass|
|
|
162
169
|
Thread.new do
|
|
170
|
+
inherited_locals.each { |key, value| Thread.current[key] = value }
|
|
163
171
|
instance = klass.new
|
|
164
172
|
instance.call(context)
|
|
165
173
|
rescue => e
|
data/lib/ask/graph/version.rb
CHANGED