ask-graph 0.1.3 → 0.2.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 +36 -0
- data/lib/ask/graph/runner.rb +67 -38
- data/lib/ask/graph/version.rb +1 -1
- data/lib/ask/graph.rb +80 -43
- 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: 965f606d4e8a059634acd10ed0e2fbf96c159ba487d30f5f2edcec9bb1b75b89
|
|
4
|
+
data.tar.gz: b419551a611da8cd81eb7ab0d31bdf0c8213d8ed829e6a30981fe0d561938e84
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fee1727d038a4a74c11571ad040b86cb1d1b18bbeda4e9e3e59c46ae1a9d2eeab34d9660fbc9674e94601a14704d014e71bf782f61848c4e681bf3efd5e8f896
|
|
7
|
+
data.tar.gz: 9f943406f882e4e4344870b05b3cc945bf03555095f0f581456b22a9f1b8596acc5091c72d6191c4e41ed0651e6c5a09a895c49b4ae3fc4a8550dc6f0fdd3c84
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
+
## [0.2.0] — 2026-07-29
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Step timeouts** — `step SlowOp, timeout: 30` raises `StepFailed` if a step takes longer than the given seconds.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
step FetchApi, timeout: 10, retry: 2
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- **Retry policy** — `step FlakyOp, retry: 3` retries failed steps up to the specified number of times with exponential backoff.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
step ApiCall, retry: 3
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **Lifecycle hooks** — `before_step`, `after_step`, and `on_failure` hooks for observability, logging, and monitoring.
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
class MyGraph < Ask::Graph
|
|
21
|
+
before_step :log_start
|
|
22
|
+
after_step :log_completion
|
|
23
|
+
on_failure :alert_team
|
|
24
|
+
|
|
25
|
+
step ProcessPayment, timeout: 15, retry: 2
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Hooks receive `declaration:` and `context:` keyword args. `on_failure` also receives `error:`.
|
|
30
|
+
|
|
31
|
+
- **Step metadata** — `step X, description: "Human-readable label"` stores metadata in the declaration for debugging and monitoring.
|
|
32
|
+
|
|
33
|
+
### Tested
|
|
34
|
+
|
|
35
|
+
- 55 tests, 73 assertions, 0 failures
|
|
36
|
+
|
|
1
37
|
## [0.1.3] — 2026-07-28
|
|
2
38
|
|
|
3
39
|
### Changed
|
data/lib/ask/graph/runner.rb
CHANGED
|
@@ -1,41 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "timeout"
|
|
4
5
|
|
|
5
6
|
module Ask
|
|
6
7
|
class Graph
|
|
7
8
|
# Error raised when a step fails.
|
|
8
9
|
class StepFailed < StandardError; end
|
|
9
10
|
|
|
11
|
+
# Error raised when a step times out.
|
|
12
|
+
class StepTimeout < StandardError; end
|
|
13
|
+
|
|
10
14
|
# Error raised when a step pauses for human approval.
|
|
11
15
|
class Paused < StandardError; end
|
|
12
16
|
|
|
13
17
|
# Executes the declared steps of a graph, managing checkpointing,
|
|
14
|
-
# condition evaluation, parallel execution,
|
|
15
|
-
#
|
|
16
|
-
# Checkpoints save the full context state after each step so that
|
|
17
|
-
# crashes resume from the last completed step with all data intact.
|
|
18
|
-
# Uses an in-memory store by default; pass a persistent store for
|
|
19
|
-
# crash recovery across restarts.
|
|
18
|
+
# condition evaluation, parallel execution, timeouts, retries,
|
|
19
|
+
# lifecycle hooks, and human-in-the-loop pauses.
|
|
20
20
|
class Runner
|
|
21
|
-
|
|
22
|
-
attr_reader :declarations
|
|
23
|
-
|
|
24
|
-
# @return [#set, #get, #delete] the checkpoint store
|
|
25
|
-
attr_reader :store
|
|
21
|
+
attr_reader :declarations, :store
|
|
26
22
|
|
|
27
23
|
# @param declarations [Array<Hash>] step declarations
|
|
28
|
-
# @param checkpoint_store [#set, #get, #delete] key-value store
|
|
29
|
-
#
|
|
30
|
-
|
|
24
|
+
# @param checkpoint_store [#set, #get, #delete] key-value store
|
|
25
|
+
# @param hooks [Hash] lifecycle hook method names
|
|
26
|
+
# @param graph_instance [Object] the graph instance (for hooks)
|
|
27
|
+
def initialize(declarations, checkpoint_store: nil,
|
|
28
|
+
hooks: { before_step: [], after_step: [], on_failure: [] },
|
|
29
|
+
graph_instance: nil)
|
|
31
30
|
@declarations = declarations
|
|
32
31
|
@store = checkpoint_store
|
|
32
|
+
@hooks = hooks
|
|
33
|
+
@graph = graph_instance
|
|
33
34
|
@completed_steps = []
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
# Run the graph from the beginning or resume from the last checkpoint.
|
|
37
|
-
# @param context [Ask::Graph::Context] the shared context
|
|
38
|
-
# @return [Ask::Graph::Context] the completed context
|
|
39
37
|
def run(context)
|
|
40
38
|
resume_index = load_checkpoint(context)
|
|
41
39
|
|
|
@@ -47,7 +45,10 @@ module Ask
|
|
|
47
45
|
next
|
|
48
46
|
end
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
run_hooks(:before_step, decl, context)
|
|
49
|
+
execute_with_retry(decl, context)
|
|
50
|
+
run_hooks(:after_step, decl, context)
|
|
51
|
+
|
|
51
52
|
record_completion(idx, :completed)
|
|
52
53
|
save_checkpoint(context, idx)
|
|
53
54
|
end
|
|
@@ -55,26 +56,17 @@ module Ask
|
|
|
55
56
|
context
|
|
56
57
|
end
|
|
57
58
|
|
|
58
|
-
# Resume a paused graph with human input.
|
|
59
|
-
# @param context [Ask::Graph::Context] the context at pause point
|
|
60
|
-
# @param input [Object] the human's input
|
|
61
|
-
# @return [Ask::Graph::Context] the completed context
|
|
62
59
|
def resume(context, input:)
|
|
63
60
|
context.resume_input = input
|
|
64
61
|
run(context)
|
|
65
62
|
end
|
|
66
63
|
|
|
67
|
-
# Get the index to resume from for an {Context#each} iteration.
|
|
68
|
-
# @param items [Array] the full list of items
|
|
69
|
-
# @return [Integer, nil] the index to resume from, or nil
|
|
70
64
|
def resume_index_for(items)
|
|
71
65
|
key = each_checkpoint_key
|
|
72
66
|
raw = @store.get(key)
|
|
73
67
|
raw ? raw.to_i : nil
|
|
74
68
|
end
|
|
75
69
|
|
|
76
|
-
# Checkpoint after a single iteration of {Context#each}.
|
|
77
|
-
# @param index [Integer] the completed iteration index
|
|
78
70
|
def checkpoint_each!(index)
|
|
79
71
|
key = each_checkpoint_key
|
|
80
72
|
@store.set(key, index.to_s)
|
|
@@ -82,6 +74,49 @@ module Ask
|
|
|
82
74
|
|
|
83
75
|
private
|
|
84
76
|
|
|
77
|
+
def execute_with_retry(decl, context)
|
|
78
|
+
retries = decl[:retry] || 0
|
|
79
|
+
|
|
80
|
+
(retries + 1).times do |attempt|
|
|
81
|
+
begin
|
|
82
|
+
run_with_timeout(decl, context, decl[:timeout])
|
|
83
|
+
return
|
|
84
|
+
rescue StepFailed => e
|
|
85
|
+
run_hooks(:on_failure, decl, context, error: e)
|
|
86
|
+
raise if attempt >= retries
|
|
87
|
+
sleep backoff_seconds(attempt, decl[:backoff])
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def run_with_timeout(decl, context, timeout_value)
|
|
93
|
+
if timeout_value
|
|
94
|
+
::Timeout.timeout(timeout_value) { execute_step(decl, context) }
|
|
95
|
+
else
|
|
96
|
+
execute_step(decl, context)
|
|
97
|
+
end
|
|
98
|
+
rescue ::Timeout::Error
|
|
99
|
+
raise StepFailed, "#{decl[:name]} timed out after #{timeout_value}s"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def backoff_seconds(attempt, strategy)
|
|
103
|
+
return 0 if attempt == 0
|
|
104
|
+
case strategy
|
|
105
|
+
when :exponential then 2 ** attempt
|
|
106
|
+
when :constant then 2
|
|
107
|
+
else 2 ** attempt
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def run_hooks(hook_type, decl, context, error: nil)
|
|
112
|
+
@hooks[hook_type]&.each do |method_name|
|
|
113
|
+
next unless @graph&.respond_to?(method_name, true)
|
|
114
|
+
args = { declaration: decl, context: context }
|
|
115
|
+
args[:error] = error if error
|
|
116
|
+
@graph.send(method_name, **args)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
85
120
|
def execute_step(decl, context)
|
|
86
121
|
case decl[:type]
|
|
87
122
|
when :step
|
|
@@ -98,19 +133,17 @@ module Ask
|
|
|
98
133
|
instance.call(context)
|
|
99
134
|
rescue Paused
|
|
100
135
|
raise
|
|
101
|
-
rescue
|
|
136
|
+
rescue => e
|
|
102
137
|
raise StepFailed, "#{klass.name} failed: #{e.message}"
|
|
103
138
|
end
|
|
104
139
|
|
|
105
140
|
def run_parallel(classes, _name, context)
|
|
106
141
|
threads = classes.map do |klass|
|
|
107
142
|
Thread.new do
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
raise StepFailed, "#{klass.name} failed: #{e.message}"
|
|
113
|
-
end
|
|
143
|
+
instance = klass.new
|
|
144
|
+
instance.call(context)
|
|
145
|
+
rescue => e
|
|
146
|
+
raise StepFailed, "#{klass.name} failed: #{e.message}"
|
|
114
147
|
end
|
|
115
148
|
end
|
|
116
149
|
threads.each(&:join)
|
|
@@ -149,8 +182,6 @@ module Ask
|
|
|
149
182
|
"#{checkpoint_key}:each"
|
|
150
183
|
end
|
|
151
184
|
|
|
152
|
-
# Save checkpoint with full context state so resumed graphs
|
|
153
|
-
# have access to all data produced by completed steps.
|
|
154
185
|
def save_checkpoint(context, idx)
|
|
155
186
|
data = {
|
|
156
187
|
completed_index: idx,
|
|
@@ -160,8 +191,6 @@ module Ask
|
|
|
160
191
|
@store.set(checkpoint_key, JSON.generate(data))
|
|
161
192
|
end
|
|
162
193
|
|
|
163
|
-
# Load checkpoint and restore context state if available.
|
|
164
|
-
# Returns the completed_index to skip, or nil for a fresh run.
|
|
165
194
|
def load_checkpoint(context)
|
|
166
195
|
raw = @store.get(checkpoint_key)
|
|
167
196
|
return nil unless raw
|
data/lib/ask/graph/version.rb
CHANGED
data/lib/ask/graph.rb
CHANGED
|
@@ -6,8 +6,8 @@ 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
|
-
#
|
|
9
|
+
# parallel execution, human-in-the-loop approval, per-item checkpointing
|
|
10
|
+
# loops, step timeouts, retry policies, and lifecycle hooks.
|
|
11
11
|
#
|
|
12
12
|
# @example Basic workflow
|
|
13
13
|
# class HandleCall < Ask::Graph
|
|
@@ -16,11 +16,21 @@ module Ask
|
|
|
16
16
|
# step BookAppointment
|
|
17
17
|
# end
|
|
18
18
|
#
|
|
19
|
-
#
|
|
19
|
+
# @example With timeout and retry
|
|
20
|
+
# class ApiWorkflow < Ask::Graph
|
|
21
|
+
# step FetchData, timeout: 30, retry: 3
|
|
22
|
+
# step ProcessData, description: "Transform raw data into reports"
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @example With lifecycle hooks
|
|
26
|
+
# class MonitoredWorkflow < Ask::Graph
|
|
27
|
+
# before_step :log_start
|
|
28
|
+
# after_step :log_completion
|
|
29
|
+
# on_failure :alert_team
|
|
20
30
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
31
|
+
# step ValidateOrder
|
|
32
|
+
# step ProcessPayment, timeout: 15, retry: 2
|
|
33
|
+
# end
|
|
24
34
|
#
|
|
25
35
|
class Graph
|
|
26
36
|
class << self
|
|
@@ -33,33 +43,60 @@ module Ask
|
|
|
33
43
|
def inherited(subclass)
|
|
34
44
|
super
|
|
35
45
|
subclass.instance_variable_set(:@declarations, declarations.dup)
|
|
46
|
+
subclass.instance_variable_set(:@lifecycle_hooks, lifecycle_hooks.dup)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# --- Lifecycle hooks ---
|
|
50
|
+
|
|
51
|
+
def lifecycle_hooks
|
|
52
|
+
@lifecycle_hooks ||= { before_step: [], after_step: [], on_failure: [] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Register a callback to run before every step.
|
|
56
|
+
# @param method_name [Symbol] method name on the graph class
|
|
57
|
+
def before_step(method_name)
|
|
58
|
+
lifecycle_hooks[:before_step] << method_name
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Register a callback to run after every successful step.
|
|
62
|
+
# @param method_name [Symbol] method name on the graph class
|
|
63
|
+
def after_step(method_name)
|
|
64
|
+
lifecycle_hooks[:after_step] << method_name
|
|
36
65
|
end
|
|
37
66
|
|
|
67
|
+
# Register a callback to run when a step fails.
|
|
68
|
+
# @param method_name [Symbol] method name on the graph class
|
|
69
|
+
def on_failure(method_name)
|
|
70
|
+
lifecycle_hooks[:on_failure] << method_name
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# --- Step declarations ---
|
|
74
|
+
|
|
38
75
|
# Declare a sequential step.
|
|
39
76
|
#
|
|
40
77
|
# @param klass [Class] a class that responds to +#call(context)+
|
|
41
|
-
# @param conditions [Hash] optional +if:+ or +unless:+ condition method name
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
name: klass.name || klass.to_s,
|
|
47
|
-
if: conditions[:if],
|
|
48
|
-
unless: conditions[:unless]
|
|
49
|
-
}
|
|
78
|
+
# @param conditions [Hash] optional +if:+ or +unless:+ condition method name,
|
|
79
|
+
# +description:+ human-readable label, +timeout:+ seconds,
|
|
80
|
+
# +retry:+ number of retries on failure
|
|
81
|
+
def step(klass, **opts)
|
|
82
|
+
declarations << build_declaration(:step, klass, opts)
|
|
50
83
|
end
|
|
51
84
|
|
|
52
85
|
# Declare parallel steps — all run simultaneously.
|
|
53
86
|
#
|
|
54
87
|
# @param klasses [Array<Class>] step classes to run in parallel
|
|
55
|
-
# @param
|
|
56
|
-
|
|
88
|
+
# @param opts [Hash] optional +if:+, +unless:+, +timeout:+ seconds,
|
|
89
|
+
# +retry:+ number of retries
|
|
90
|
+
def steps(*klasses, **opts)
|
|
57
91
|
declarations << {
|
|
58
92
|
classes: klasses,
|
|
59
93
|
type: :steps,
|
|
60
94
|
name: klasses.map { |k| k.name || k.to_s }.join(", "),
|
|
61
|
-
if:
|
|
62
|
-
unless:
|
|
95
|
+
if: opts[:if],
|
|
96
|
+
unless: opts[:unless],
|
|
97
|
+
description: opts[:description],
|
|
98
|
+
timeout: opts[:timeout],
|
|
99
|
+
retry: opts[:retry]
|
|
63
100
|
}
|
|
64
101
|
end
|
|
65
102
|
|
|
@@ -67,45 +104,48 @@ module Ask
|
|
|
67
104
|
# and waits for external input via {Ask::Graph#resume}.
|
|
68
105
|
#
|
|
69
106
|
# @param klass [Class] a class that responds to +#call(context)+
|
|
70
|
-
# @param
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
type: :approve,
|
|
75
|
-
name: klass.name || klass.to_s,
|
|
76
|
-
if: conditions[:if],
|
|
77
|
-
unless: conditions[:unless]
|
|
78
|
-
}
|
|
107
|
+
# @param opts [Hash] optional +if:+, +unless:+, +description:+,
|
|
108
|
+
# +timeout:+, +retry:+
|
|
109
|
+
def approve(klass, **opts)
|
|
110
|
+
declarations << build_declaration(:approve, klass, opts)
|
|
79
111
|
end
|
|
80
112
|
|
|
81
113
|
# Convenience: create instance and call.
|
|
82
|
-
# @param input [Object] optional input
|
|
83
|
-
# @param checkpoint_store [#set, #get, #delete] optional persistent store
|
|
84
|
-
# @return [Ask::Graph::Context] the completed context
|
|
85
114
|
def call(input = nil, checkpoint_store: nil)
|
|
86
115
|
new(input, checkpoint_store: checkpoint_store).call
|
|
87
116
|
end
|
|
88
117
|
alias run call
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def build_declaration(type, klass, opts)
|
|
122
|
+
{
|
|
123
|
+
class: klass,
|
|
124
|
+
type: type,
|
|
125
|
+
name: klass.name || klass.to_s,
|
|
126
|
+
if: opts[:if],
|
|
127
|
+
unless: opts[:unless],
|
|
128
|
+
description: opts[:description],
|
|
129
|
+
timeout: opts[:timeout],
|
|
130
|
+
retry: opts[:retry]
|
|
131
|
+
}
|
|
132
|
+
end
|
|
89
133
|
end
|
|
90
134
|
|
|
91
|
-
# @param input [Object, nil] initial input for the graph execution
|
|
92
|
-
# @param checkpoint_store [#set, #get, #delete, nil] optional persistent store.
|
|
93
|
-
# Defaults to in-memory store. Pass a persistent store for crash recovery.
|
|
94
135
|
def initialize(input = nil, checkpoint_store: nil)
|
|
95
136
|
@input = input
|
|
96
137
|
store = checkpoint_store || Ask::State::Memory.new
|
|
97
|
-
|
|
138
|
+
hooks = self.class.lifecycle_hooks
|
|
139
|
+
@runner = Runner.new(self.class.declarations,
|
|
140
|
+
checkpoint_store: store,
|
|
141
|
+
hooks: hooks,
|
|
142
|
+
graph_instance: self)
|
|
98
143
|
@context = nil
|
|
99
144
|
end
|
|
100
145
|
|
|
101
|
-
# @return [Ask::Graph::Runner] the runner (exposed for checkpoint coordination)
|
|
102
146
|
attr_reader :runner
|
|
103
|
-
|
|
104
|
-
# @return [Ask::Graph::Context, nil] the shared context
|
|
105
147
|
attr_reader :context
|
|
106
148
|
|
|
107
|
-
# Execute the graph.
|
|
108
|
-
# @return [Ask::Graph::Context] the completed context
|
|
109
149
|
def call
|
|
110
150
|
@context = Context.new(self, @input)
|
|
111
151
|
@runner.run(@context)
|
|
@@ -116,9 +156,6 @@ module Ask
|
|
|
116
156
|
end
|
|
117
157
|
alias run call
|
|
118
158
|
|
|
119
|
-
# Resume a paused graph with human input.
|
|
120
|
-
# @param input [Object] the human's input
|
|
121
|
-
# @return [Ask::Graph::Context] the completed context
|
|
122
159
|
def resume(input:)
|
|
123
160
|
@context.resume_input = input.to_s
|
|
124
161
|
@runner.run(@context)
|