ask-graph 0.3.0 → 0.5.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/CHANGELOG.md +95 -0
- data/lib/ask/graph/context.rb +22 -1
- data/lib/ask/graph/runner.rb +21 -6
- data/lib/ask/graph/version.rb +1 -1
- data/lib/ask/graph.rb +53 -7
- 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: 5bcdf4454ef1639db46defba3c2b59377dd0aa719036b27be25b77a7a805fdec
|
|
4
|
+
data.tar.gz: eef23d886d551984bbd8f1514e7bdfacc38be0d4b029ec832b1e7c61f2d2036f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bef581ebc5816f79ab0b8d7da98dd9cf53fba824e1e68691e5b39ef8ee0eca3380fa2994dde965ad17db24267f9541b4c537c87fa26a3852ba9fa128afc60237
|
|
7
|
+
data.tar.gz: 78d63a0b802ee4050d09a08ab4d6f62a7fa8e71425745ef055d2f02824c6fb92f48c3149fadb275723aefb93b04bf0a04c258246b115c4ad028b5a56179a9188
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,98 @@
|
|
|
1
|
+
## [0.5.0] — 2026-07-27
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
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.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class NotifyCustomer < Ask::Graph
|
|
11
|
+
step SendEmail
|
|
12
|
+
step LogNotification
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class HandleOrder < Ask::Graph
|
|
16
|
+
step ValidatePayment
|
|
17
|
+
step NotifyCustomer # runs the sub-graph, merges its context
|
|
18
|
+
step ShipOrder
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
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)
|
|
25
|
+
- Supports `timeout:`, `retry:`, and `if:`/`unless:` on the outer step
|
|
26
|
+
- Supports parallel steps inside sub-graphs
|
|
27
|
+
- Shares the same `storage` backend with the outer graph
|
|
28
|
+
|
|
29
|
+
- **`Context#export_data`** — returns the raw store hash without JSON
|
|
30
|
+
serialization. Used internally for sub-graph data passing.
|
|
31
|
+
|
|
32
|
+
- **`Context#import(other)`** — merges data from another context or hash
|
|
33
|
+
into this one. Used internally by sub-graph composition.
|
|
34
|
+
|
|
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.
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
g.new({ value: 42 }).call
|
|
42
|
+
ctx.value # => 42 (new — directly accessible)
|
|
43
|
+
ctx.input # => { value: 42 } (still works)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Limitations
|
|
47
|
+
|
|
48
|
+
- Sub-graphs containing `approve` steps are not yet supported and raise
|
|
49
|
+
`ArgumentError`. Nested approval will be added in a future release.
|
|
50
|
+
|
|
51
|
+
### Tested
|
|
52
|
+
|
|
53
|
+
- 87 tests, 114 assertions, 0 failures
|
|
54
|
+
|
|
55
|
+
## [0.4.0] — 2026-07-27
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- **`checkpoint_store:` renamed to `storage:`** — shorter, more general name
|
|
60
|
+
for the backend that persists checkpoint data for crash recovery.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# Before
|
|
64
|
+
MyGraph.call(input, checkpoint_store: RedisPool.new)
|
|
65
|
+
g.new(input, checkpoint_store: RedisPool.new)
|
|
66
|
+
|
|
67
|
+
# After
|
|
68
|
+
MyGraph.call(input, storage: RedisPool.new)
|
|
69
|
+
g.new(input, storage: RedisPool.new)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
|
|
74
|
+
- **Class-level `storage`** — set once at the class level instead of passing
|
|
75
|
+
per-call. Works the same as `timeout`: per-call → graph class → global.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# Global default for all graphs
|
|
79
|
+
Ask::Graph.storage RedisPool.new
|
|
80
|
+
|
|
81
|
+
# Per-graph override
|
|
82
|
+
class MyGraph < Ask::Graph
|
|
83
|
+
storage PostgresStore.new
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Per-call override (overrides everything)
|
|
87
|
+
MyGraph.call(input, storage: InMemory.new)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Falls back to `Ask::State::Memory.new` when nothing is configured.
|
|
91
|
+
|
|
92
|
+
### Tested
|
|
93
|
+
|
|
94
|
+
- 70 tests, 92 assertions, 0 failures
|
|
95
|
+
|
|
1
96
|
## [0.3.0] — 2026-07-27
|
|
2
97
|
|
|
3
98
|
### Added
|
data/lib/ask/graph/context.rb
CHANGED
|
@@ -31,7 +31,12 @@ module Ask
|
|
|
31
31
|
@item = nil
|
|
32
32
|
@mutex = Mutex.new
|
|
33
33
|
@resume_input = nil
|
|
34
|
-
|
|
34
|
+
if input.is_a?(Hash)
|
|
35
|
+
@store[:input] = input
|
|
36
|
+
@store.merge!(input)
|
|
37
|
+
elsif input
|
|
38
|
+
@store[:input] = input
|
|
39
|
+
end
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
# @return [Object, nil] input provided on resume for approval steps
|
|
@@ -102,6 +107,22 @@ module Ask
|
|
|
102
107
|
end
|
|
103
108
|
end
|
|
104
109
|
|
|
110
|
+
# @return [Hash] raw store data with original object references.
|
|
111
|
+
# Unlike {to_h}, this returns the actual Ruby objects without
|
|
112
|
+
# serialization. Useful for passing state to sub-graphs.
|
|
113
|
+
def export_data
|
|
114
|
+
@mutex.synchronize { @store.dup }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Merge data from another context (or hash) into this one.
|
|
118
|
+
# Existing keys in this context are overwritten.
|
|
119
|
+
#
|
|
120
|
+
# @param other [Context, Hash] source of data to merge in
|
|
121
|
+
def import(other)
|
|
122
|
+
data = other.is_a?(Context) ? other.export_data : other
|
|
123
|
+
@mutex.synchronize { @store.merge!(data) }
|
|
124
|
+
end
|
|
125
|
+
|
|
105
126
|
private
|
|
106
127
|
|
|
107
128
|
# Recursively convert a value to a JSON-safe structure.
|
data/lib/ask/graph/runner.rb
CHANGED
|
@@ -21,16 +21,16 @@ module Ask
|
|
|
21
21
|
attr_reader :declarations, :store
|
|
22
22
|
|
|
23
23
|
# @param declarations [Array<Hash>] step declarations
|
|
24
|
-
# @param
|
|
24
|
+
# @param storage [#set, #get, #delete] key-value store for checkpoint data
|
|
25
25
|
# @param hooks [Hash] lifecycle hook method names
|
|
26
26
|
# @param graph_instance [Object] the graph instance (for hooks)
|
|
27
27
|
# @param default_timeout [Integer, nil] fallback timeout for steps with no explicit timeout
|
|
28
|
-
def initialize(declarations,
|
|
28
|
+
def initialize(declarations, storage: nil,
|
|
29
29
|
hooks: { before_step: [], after_step: [], on_failure: [] },
|
|
30
30
|
graph_instance: nil,
|
|
31
31
|
default_timeout: nil)
|
|
32
32
|
@declarations = declarations
|
|
33
|
-
@store =
|
|
33
|
+
@store = storage
|
|
34
34
|
@hooks = hooks
|
|
35
35
|
@graph = graph_instance
|
|
36
36
|
@default_timeout = default_timeout
|
|
@@ -133,14 +133,29 @@ module Ask
|
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
def run_single(klass, _name, context)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
139
143
|
raise
|
|
140
144
|
rescue => e
|
|
141
145
|
raise StepFailed, "#{klass.name} failed: #{e.message}"
|
|
142
146
|
end
|
|
143
147
|
|
|
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
|
+
|
|
144
159
|
def run_parallel(classes, _name, context)
|
|
145
160
|
threads = classes.map do |klass|
|
|
146
161
|
Thread.new do
|
data/lib/ask/graph/version.rb
CHANGED
data/lib/ask/graph.rb
CHANGED
|
@@ -6,8 +6,9 @@ require_relative "graph/runner"
|
|
|
6
6
|
|
|
7
7
|
module Ask
|
|
8
8
|
# Define durable workflow graphs with named steps, conditional routing,
|
|
9
|
-
# parallel execution, human-in-the-loop approval,
|
|
10
|
-
# loops, step timeouts, retry policies, and
|
|
9
|
+
# parallel execution, sub-graph composition, human-in-the-loop approval,
|
|
10
|
+
# per-item checkpointing loops, step timeouts, retry policies, and
|
|
11
|
+
# lifecycle hooks.
|
|
11
12
|
#
|
|
12
13
|
# @example Basic workflow
|
|
13
14
|
# class HandleCall < Ask::Graph
|
|
@@ -16,6 +17,18 @@ module Ask
|
|
|
16
17
|
# step BookAppointment
|
|
17
18
|
# end
|
|
18
19
|
#
|
|
20
|
+
# @example Sub-graph composition (reuse a graph as a step)
|
|
21
|
+
# class NotifyCustomer < Ask::Graph
|
|
22
|
+
# step SendEmail
|
|
23
|
+
# step LogNotification
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# class HandleOrder < Ask::Graph
|
|
27
|
+
# step ValidatePayment
|
|
28
|
+
# step NotifyCustomer # runs the sub-graph, merges its context
|
|
29
|
+
# step ShipOrder
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
19
32
|
# @example With timeout and retry
|
|
20
33
|
# class ApiWorkflow < Ask::Graph
|
|
21
34
|
# step FetchData, timeout: 30, retry: 3
|
|
@@ -77,6 +90,39 @@ module Ask
|
|
|
77
90
|
end
|
|
78
91
|
end
|
|
79
92
|
|
|
93
|
+
# --- Storage ---
|
|
94
|
+
|
|
95
|
+
# Set or get the default storage backend for checkpoint data.
|
|
96
|
+
# The storage object must respond to +#set(key, value)+ and +#get(key)+.
|
|
97
|
+
#
|
|
98
|
+
# Setting on {Ask::Graph} itself applies to all graphs that don't set
|
|
99
|
+
# their own storage — a global default.
|
|
100
|
+
#
|
|
101
|
+
# @example Global default for all graphs
|
|
102
|
+
# Ask::Graph.storage RedisPool.new
|
|
103
|
+
#
|
|
104
|
+
# @example Per-graph override
|
|
105
|
+
# class MyGraph < Ask::Graph
|
|
106
|
+
# storage PostgresStore.new
|
|
107
|
+
# end
|
|
108
|
+
#
|
|
109
|
+
# @example Per-call override
|
|
110
|
+
# MyGraph.call(input, storage: InMemory.new)
|
|
111
|
+
#
|
|
112
|
+
# @param store [Object, nil] a backend that responds to +#set+ and +#get+
|
|
113
|
+
# @return [Object, nil] the current storage for this graph
|
|
114
|
+
def storage(store = :not_set)
|
|
115
|
+
if store == :not_set
|
|
116
|
+
if instance_variable_defined?(:@storage)
|
|
117
|
+
@storage
|
|
118
|
+
elsif superclass.respond_to?(:storage)
|
|
119
|
+
superclass.storage
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
@storage = store
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
80
126
|
# --- Lifecycle hooks ---
|
|
81
127
|
|
|
82
128
|
def lifecycle_hooks
|
|
@@ -142,8 +188,8 @@ module Ask
|
|
|
142
188
|
end
|
|
143
189
|
|
|
144
190
|
# Convenience: create instance and call.
|
|
145
|
-
def call(input = nil,
|
|
146
|
-
new(input,
|
|
191
|
+
def call(input = nil, storage: nil)
|
|
192
|
+
new(input, storage: storage).call
|
|
147
193
|
end
|
|
148
194
|
alias run call
|
|
149
195
|
|
|
@@ -163,12 +209,12 @@ module Ask
|
|
|
163
209
|
end
|
|
164
210
|
end
|
|
165
211
|
|
|
166
|
-
def initialize(input = nil,
|
|
212
|
+
def initialize(input = nil, storage: nil)
|
|
167
213
|
@input = input
|
|
168
|
-
store =
|
|
214
|
+
store = storage || self.class.storage || Ask::State::Memory.new
|
|
169
215
|
hooks = self.class.lifecycle_hooks
|
|
170
216
|
@runner = Runner.new(self.class.declarations,
|
|
171
|
-
|
|
217
|
+
storage: store,
|
|
172
218
|
hooks: hooks,
|
|
173
219
|
graph_instance: self,
|
|
174
220
|
default_timeout: self.class.timeout)
|