ask-graph 0.1.3 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad07fb878d69dbb0a0bd0065e5c26275bfeda6169fa36fb6aa5688e129534b20
4
- data.tar.gz: b583bbe7555a92bfdb2bb5430e7498925e6aa6019f185e5147780861f9da94d8
3
+ metadata.gz: 59959ae9ec12f0d79aecfe21ae07748baa067bb47efb9c519d291dd352032873
4
+ data.tar.gz: ea54f68d9693259b6fbe2833693391e7844c26b9f442551af09fe3d1e4d6b84d
5
5
  SHA512:
6
- metadata.gz: b2baa9116d362d868462bd420c968a52b887f27df32fef51f040180012df815af84b1f33b29ddc7794e896bae3e53779995019ce9515835a83371b9d3d572513
7
- data.tar.gz: 7b3bcde81e887dddb9b9e2a51f2844eff4ea4d5f4197f40d41c3bf15c5d79b986b2153d409ebf37d902b3c05fdddf55a2596de153cd4b2732fd501d7786234fc
6
+ metadata.gz: 221a7565d2feb4ac3769793dbb510687b56f0058bc259ecf94a32acb59e4d8dcebe23a4d7cc14888a6001ffc53a3e06bbc940e45032138dcd734fb6476b76a28
7
+ data.tar.gz: 68e22b21cffae4f146781926d89dd7c767f31234212181ce60707f9dd8e35ec90841f868677517d772a0221ca80fe7a53b15f93a7a067f6c5a280c4ebd5e7ae4
data/CHANGELOG.md CHANGED
@@ -1,3 +1,69 @@
1
+ ## [0.3.0] — 2026-07-27
2
+
3
+ ### Added
4
+
5
+ - **Class-level `timeout`** — set a default timeout for all steps in a graph.
6
+ Steps without an explicit `timeout:` option inherit the class default.
7
+
8
+ ```ruby
9
+ class MyGraph < Ask::Graph
10
+ timeout 30
11
+ step FastOp # uses 30s
12
+ step SlowOp, timeout: 120 # overrides
13
+ end
14
+ ```
15
+
16
+ - **Global default timeout** — `Ask::Graph.timeout 30` applies to all graphs
17
+ that don't set their own `timeout`. Resolution order: step option → graph
18
+ class → `Ask::Graph`.
19
+
20
+ ```ruby
21
+ Ask::Graph.timeout 30
22
+ ```
23
+
24
+ - **Child override and clearing** — child graphs can override or clear
25
+ (`timeout nil`) the parent's default.
26
+
27
+ ### Tested
28
+
29
+ - 62 tests, 83 assertions, 0 failures
30
+
31
+ ## [0.2.0] — 2026-07-29
32
+
33
+ ### Added
34
+
35
+ - **Step timeouts** — `step SlowOp, timeout: 30` raises `StepFailed` if a step takes longer than the given seconds.
36
+
37
+ ```ruby
38
+ step FetchApi, timeout: 10, retry: 2
39
+ ```
40
+
41
+ - **Retry policy** — `step FlakyOp, retry: 3` retries failed steps up to the specified number of times with exponential backoff.
42
+
43
+ ```ruby
44
+ step ApiCall, retry: 3
45
+ ```
46
+
47
+ - **Lifecycle hooks** — `before_step`, `after_step`, and `on_failure` hooks for observability, logging, and monitoring.
48
+
49
+ ```ruby
50
+ class MyGraph < Ask::Graph
51
+ before_step :log_start
52
+ after_step :log_completion
53
+ on_failure :alert_team
54
+
55
+ step ProcessPayment, timeout: 15, retry: 2
56
+ end
57
+ ```
58
+
59
+ Hooks receive `declaration:` and `context:` keyword args. `on_failure` also receives `error:`.
60
+
61
+ - **Step metadata** — `step X, description: "Human-readable label"` stores metadata in the declaration for debugging and monitoring.
62
+
63
+ ### Tested
64
+
65
+ - 55 tests, 73 assertions, 0 failures
66
+
1
67
  ## [0.1.3] — 2026-07-28
2
68
 
3
69
  ### Changed
@@ -1,41 +1,42 @@
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, and human-in-the-loop pauses.
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
- # @return [Array<Hash>] step declarations with name, type, condition
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
- # Defaults to in-memory (no durability across restarts).
30
- def initialize(declarations, checkpoint_store: nil)
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
+ # @param default_timeout [Integer, nil] fallback timeout for steps with no explicit timeout
28
+ def initialize(declarations, checkpoint_store: nil,
29
+ hooks: { before_step: [], after_step: [], on_failure: [] },
30
+ graph_instance: nil,
31
+ default_timeout: nil)
31
32
  @declarations = declarations
32
33
  @store = checkpoint_store
34
+ @hooks = hooks
35
+ @graph = graph_instance
36
+ @default_timeout = default_timeout
33
37
  @completed_steps = []
34
38
  end
35
39
 
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
40
  def run(context)
40
41
  resume_index = load_checkpoint(context)
41
42
 
@@ -47,7 +48,10 @@ module Ask
47
48
  next
48
49
  end
49
50
 
50
- execute_step(decl, context)
51
+ run_hooks(:before_step, decl, context)
52
+ execute_with_retry(decl, context)
53
+ run_hooks(:after_step, decl, context)
54
+
51
55
  record_completion(idx, :completed)
52
56
  save_checkpoint(context, idx)
53
57
  end
@@ -55,26 +59,17 @@ module Ask
55
59
  context
56
60
  end
57
61
 
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
62
  def resume(context, input:)
63
63
  context.resume_input = input
64
64
  run(context)
65
65
  end
66
66
 
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
67
  def resume_index_for(items)
71
68
  key = each_checkpoint_key
72
69
  raw = @store.get(key)
73
70
  raw ? raw.to_i : nil
74
71
  end
75
72
 
76
- # Checkpoint after a single iteration of {Context#each}.
77
- # @param index [Integer] the completed iteration index
78
73
  def checkpoint_each!(index)
79
74
  key = each_checkpoint_key
80
75
  @store.set(key, index.to_s)
@@ -82,6 +77,50 @@ module Ask
82
77
 
83
78
  private
84
79
 
80
+ def execute_with_retry(decl, context)
81
+ retries = decl[:retry] || 0
82
+
83
+ (retries + 1).times do |attempt|
84
+ begin
85
+ run_with_timeout(decl, context, decl[:timeout])
86
+ return
87
+ rescue StepFailed => e
88
+ run_hooks(:on_failure, decl, context, error: e)
89
+ raise if attempt >= retries
90
+ sleep backoff_seconds(attempt, decl[:backoff])
91
+ end
92
+ end
93
+ end
94
+
95
+ def run_with_timeout(decl, context, timeout_value)
96
+ timeout_value ||= @default_timeout
97
+ if timeout_value
98
+ ::Timeout.timeout(timeout_value) { execute_step(decl, context) }
99
+ else
100
+ execute_step(decl, context)
101
+ end
102
+ rescue ::Timeout::Error
103
+ raise StepFailed, "#{decl[:name]} timed out after #{timeout_value}s"
104
+ end
105
+
106
+ def backoff_seconds(attempt, strategy)
107
+ return 0 if attempt == 0
108
+ case strategy
109
+ when :exponential then 2 ** attempt
110
+ when :constant then 2
111
+ else 2 ** attempt
112
+ end
113
+ end
114
+
115
+ def run_hooks(hook_type, decl, context, error: nil)
116
+ @hooks[hook_type]&.each do |method_name|
117
+ next unless @graph&.respond_to?(method_name, true)
118
+ args = { declaration: decl, context: context }
119
+ args[:error] = error if error
120
+ @graph.send(method_name, **args)
121
+ end
122
+ end
123
+
85
124
  def execute_step(decl, context)
86
125
  case decl[:type]
87
126
  when :step
@@ -98,19 +137,17 @@ module Ask
98
137
  instance.call(context)
99
138
  rescue Paused
100
139
  raise
101
- rescue StandardError => e
140
+ rescue => e
102
141
  raise StepFailed, "#{klass.name} failed: #{e.message}"
103
142
  end
104
143
 
105
144
  def run_parallel(classes, _name, context)
106
145
  threads = classes.map do |klass|
107
146
  Thread.new do
108
- begin
109
- instance = klass.new
110
- instance.call(context)
111
- rescue StandardError => e
112
- raise StepFailed, "#{klass.name} failed: #{e.message}"
113
- end
147
+ instance = klass.new
148
+ instance.call(context)
149
+ rescue => e
150
+ raise StepFailed, "#{klass.name} failed: #{e.message}"
114
151
  end
115
152
  end
116
153
  threads.each(&:join)
@@ -149,8 +186,6 @@ module Ask
149
186
  "#{checkpoint_key}:each"
150
187
  end
151
188
 
152
- # Save checkpoint with full context state so resumed graphs
153
- # have access to all data produced by completed steps.
154
189
  def save_checkpoint(context, idx)
155
190
  data = {
156
191
  completed_index: idx,
@@ -160,8 +195,6 @@ module Ask
160
195
  @store.set(checkpoint_key, JSON.generate(data))
161
196
  end
162
197
 
163
- # Load checkpoint and restore context state if available.
164
- # Returns the completed_index to skip, or nil for a fresh run.
165
198
  def load_checkpoint(context)
166
199
  raw = @store.get(checkpoint_key)
167
200
  return nil unless raw
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  class Graph
5
- VERSION = "0.1.3"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
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, and per-item
10
- # checkpointing loops.
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
- # result = HandleCall.new.call(recording: "call.wav")
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
- # @example With checkpointing
22
- # store = Ask::State::Memory.new
23
- # result = HandleCall.new.call(input, checkpoint_store: store)
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,91 @@ 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
+ # --- Timeout ---
50
+
51
+ # Set or get the default timeout (in seconds) for steps in this graph.
52
+ # Steps without an explicit +timeout:+ option will inherit this value.
53
+ # Setting on {Ask::Graph} itself applies to all graphs that don't set
54
+ # their own default — a global default.
55
+ #
56
+ # @example Global default for all graphs
57
+ # Ask::Graph.timeout 30
58
+ #
59
+ # @example Per-graph override
60
+ # class MyGraph < Ask::Graph
61
+ # timeout 15
62
+ # step FastOp # uses 15s
63
+ # step SlowOp, timeout: 60 # overrides
64
+ # end
65
+ #
66
+ # @param seconds [Integer, nil] timeout in seconds, or nil to clear
67
+ # @return [Integer, nil] the current default timeout for this graph
68
+ def timeout(seconds = :not_set)
69
+ if seconds == :not_set
70
+ if instance_variable_defined?(:@timeout)
71
+ @timeout
72
+ elsif superclass.respond_to?(:timeout)
73
+ superclass.timeout
74
+ end
75
+ else
76
+ @timeout = seconds
77
+ end
78
+ end
79
+
80
+ # --- Lifecycle hooks ---
81
+
82
+ def lifecycle_hooks
83
+ @lifecycle_hooks ||= { before_step: [], after_step: [], on_failure: [] }
84
+ end
85
+
86
+ # Register a callback to run before every step.
87
+ # @param method_name [Symbol] method name on the graph class
88
+ def before_step(method_name)
89
+ lifecycle_hooks[:before_step] << method_name
90
+ end
91
+
92
+ # Register a callback to run after every successful step.
93
+ # @param method_name [Symbol] method name on the graph class
94
+ def after_step(method_name)
95
+ lifecycle_hooks[:after_step] << method_name
96
+ end
97
+
98
+ # Register a callback to run when a step fails.
99
+ # @param method_name [Symbol] method name on the graph class
100
+ def on_failure(method_name)
101
+ lifecycle_hooks[:on_failure] << method_name
36
102
  end
37
103
 
104
+ # --- Step declarations ---
105
+
38
106
  # Declare a sequential step.
39
107
  #
40
108
  # @param klass [Class] a class that responds to +#call(context)+
41
- # @param conditions [Hash] optional +if:+ or +unless:+ condition method name
42
- def step(klass, **conditions)
43
- declarations << {
44
- class: klass,
45
- type: :step,
46
- name: klass.name || klass.to_s,
47
- if: conditions[:if],
48
- unless: conditions[:unless]
49
- }
109
+ # @param conditions [Hash] optional +if:+ or +unless:+ condition method name,
110
+ # +description:+ human-readable label, +timeout:+ seconds,
111
+ # +retry:+ number of retries on failure
112
+ def step(klass, **opts)
113
+ declarations << build_declaration(:step, klass, opts)
50
114
  end
51
115
 
52
116
  # Declare parallel steps — all run simultaneously.
53
117
  #
54
118
  # @param klasses [Array<Class>] step classes to run in parallel
55
- # @param conditions [Hash] optional +if:+ or +unless:+ condition method name
56
- def steps(*klasses, **conditions)
119
+ # @param opts [Hash] optional +if:+, +unless:+, +timeout:+ seconds,
120
+ # +retry:+ number of retries
121
+ def steps(*klasses, **opts)
57
122
  declarations << {
58
123
  classes: klasses,
59
124
  type: :steps,
60
125
  name: klasses.map { |k| k.name || k.to_s }.join(", "),
61
- if: conditions[:if],
62
- unless: conditions[:unless]
126
+ if: opts[:if],
127
+ unless: opts[:unless],
128
+ description: opts[:description],
129
+ timeout: opts[:timeout],
130
+ retry: opts[:retry]
63
131
  }
64
132
  end
65
133
 
@@ -67,45 +135,49 @@ module Ask
67
135
  # and waits for external input via {Ask::Graph#resume}.
68
136
  #
69
137
  # @param klass [Class] a class that responds to +#call(context)+
70
- # @param conditions [Hash] optional +if:+ or +unless:+ condition method name
71
- def approve(klass, **conditions)
72
- declarations << {
73
- class: klass,
74
- type: :approve,
75
- name: klass.name || klass.to_s,
76
- if: conditions[:if],
77
- unless: conditions[:unless]
78
- }
138
+ # @param opts [Hash] optional +if:+, +unless:+, +description:+,
139
+ # +timeout:+, +retry:+
140
+ def approve(klass, **opts)
141
+ declarations << build_declaration(:approve, klass, opts)
79
142
  end
80
143
 
81
144
  # 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
145
  def call(input = nil, checkpoint_store: nil)
86
146
  new(input, checkpoint_store: checkpoint_store).call
87
147
  end
88
148
  alias run call
149
+
150
+ private
151
+
152
+ def build_declaration(type, klass, opts)
153
+ {
154
+ class: klass,
155
+ type: type,
156
+ name: klass.name || klass.to_s,
157
+ if: opts[:if],
158
+ unless: opts[:unless],
159
+ description: opts[:description],
160
+ timeout: opts[:timeout],
161
+ retry: opts[:retry]
162
+ }
163
+ end
89
164
  end
90
165
 
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
166
  def initialize(input = nil, checkpoint_store: nil)
95
167
  @input = input
96
168
  store = checkpoint_store || Ask::State::Memory.new
97
- @runner = Runner.new(self.class.declarations, checkpoint_store: store)
169
+ hooks = self.class.lifecycle_hooks
170
+ @runner = Runner.new(self.class.declarations,
171
+ checkpoint_store: store,
172
+ hooks: hooks,
173
+ graph_instance: self,
174
+ default_timeout: self.class.timeout)
98
175
  @context = nil
99
176
  end
100
177
 
101
- # @return [Ask::Graph::Runner] the runner (exposed for checkpoint coordination)
102
178
  attr_reader :runner
103
-
104
- # @return [Ask::Graph::Context, nil] the shared context
105
179
  attr_reader :context
106
180
 
107
- # Execute the graph.
108
- # @return [Ask::Graph::Context] the completed context
109
181
  def call
110
182
  @context = Context.new(self, @input)
111
183
  @runner.run(@context)
@@ -116,9 +188,6 @@ module Ask
116
188
  end
117
189
  alias run call
118
190
 
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
191
  def resume(input:)
123
192
  @context.resume_input = input.to_s
124
193
  @runner.run(@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.1.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto