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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad07fb878d69dbb0a0bd0065e5c26275bfeda6169fa36fb6aa5688e129534b20
4
- data.tar.gz: b583bbe7555a92bfdb2bb5430e7498925e6aa6019f185e5147780861f9da94d8
3
+ metadata.gz: 965f606d4e8a059634acd10ed0e2fbf96c159ba487d30f5f2edcec9bb1b75b89
4
+ data.tar.gz: b419551a611da8cd81eb7ab0d31bdf0c8213d8ed829e6a30981fe0d561938e84
5
5
  SHA512:
6
- metadata.gz: b2baa9116d362d868462bd420c968a52b887f27df32fef51f040180012df815af84b1f33b29ddc7794e896bae3e53779995019ce9515835a83371b9d3d572513
7
- data.tar.gz: 7b3bcde81e887dddb9b9e2a51f2844eff4ea4d5f4197f40d41c3bf15c5d79b986b2153d409ebf37d902b3c05fdddf55a2596de153cd4b2732fd501d7786234fc
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
@@ -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, 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
+ 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
- execute_step(decl, context)
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 StandardError => e
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
- begin
109
- instance = klass.new
110
- instance.call(context)
111
- rescue StandardError => e
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  class Graph
5
- VERSION = "0.1.3"
5
+ VERSION = "0.2.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,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
- 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
- }
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 conditions [Hash] optional +if:+ or +unless:+ condition method name
56
- def steps(*klasses, **conditions)
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: conditions[:if],
62
- unless: conditions[: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 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
- }
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
- @runner = Runner.new(self.class.declarations, checkpoint_store: store)
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)
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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto