ask-graph 0.1.0 → 0.1.1

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: 2bcc4cd34c3c6b401b4a5e6e47ea28bfeb8fcfe6cc117c46230ddf58df6249f8
4
- data.tar.gz: 20bec4635a80dd53b1b497c38985327efcef79a7acace61d8725fac85842e2ce
3
+ metadata.gz: 79153d7eedc0823a4831f68994771bf5b57e7e50aa872ddafff3254670d2dc7a
4
+ data.tar.gz: f5a32f4df22bfebefda9e8963684137bd620fbb47fa70cf088fed10cfb0e2d3f
5
5
  SHA512:
6
- metadata.gz: a122a01aa42024e4be602f8fe859e0b3148867c248e36d434e86b63ad62561b53ea081cc1903c033d6aa603b89e232d981b94fda01b1f6fcf09356b31ef801eb
7
- data.tar.gz: 96d289a9595599a663da22cef7357af63dc74dde4a6c399589eea339441c5a32f7b49ff77f82bfbf1232e1f548b6c3ae85aa0dd4bd2b6e430ff26a3d689d28fd
6
+ metadata.gz: 1a502848e6dec5c7fdbf47bb5cdf7d74104d8706e68d674c7b9f33da462e330aa429fb9df493aa71d8309fe57cb46352461ff54a488452e4768d5628ed7a93ae
7
+ data.tar.gz: 55b42aad5f0b6f1ec4e7bb81661ed068ebd37dabd1d95fe6429ffb09cf2ad5ba91aa747f46560a26b5f2b65e6b4b377d18be8bc07e45ea191eb4b147ec0fa0d6
data/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## [0.1.1] — 2026-07-28
2
+
3
+ ### Fixed
4
+
5
+ - **Full context checkpointing** — Checkpoints now save the complete context state alongside the step index. On resume, all data produced by completed steps is restored. Previously only the step index was persisted, causing nil errors when resumed steps referenced prior outputs.
6
+
7
+ - **`call` as primary API** — `Graph.new.call(input)` is now the primary interface, consistent with step classes. `Graph.call` and `Graph.run` remain as convenience class methods.
8
+
9
+ - **Default in-memory store** — Checkpoint store defaults to `Ask::State::Memory` (shipped with `ask-core`, zero additional dependencies). No database, migration, or configuration needed. Pass a persistent store for crash recovery across restarts.
10
+
11
+ ### Changed
12
+
13
+ - `context.to_h` now produces JSON-safe hashes (string keys, symbols converted to strings, arrays recursed). Checkpoints serialize correctly in Ruby 4.0.
14
+
15
+ - `initialize(checkpoint_store:)` stores the checkpoint store on the instance. Pass `nil` to use the default in-memory store.
16
+
17
+ ### Tested
18
+
19
+ - 36 tests, 52 assertions, 0 failures
20
+
1
21
  ## [0.1.0] — 2026-07-28
2
22
 
3
23
  ### Added
@@ -30,9 +30,13 @@ module Ask
30
30
  @each_items = nil
31
31
  @item = nil
32
32
  @mutex = Mutex.new
33
+ @resume_input = nil
33
34
  @store[:input] = input if input
34
35
  end
35
36
 
37
+ # @return [Object, nil] input provided on resume for approval steps
38
+ attr_accessor :resume_input
39
+
36
40
  # Access a stored value by method name.
37
41
  def method_missing(name, *args, &block)
38
42
  if name.to_s.end_with?("=")
@@ -89,14 +93,30 @@ module Ask
89
93
  @item = nil
90
94
  end
91
95
 
92
- # @return [Hash] serializable snapshot of the context state
96
+ # @return [Hash] serializable snapshot of the context state.
97
+ # All keys and values are converted to JSON-safe types (string keys,
98
+ # symbols converted to strings, arrays recursed).
93
99
  def to_h
94
100
  @mutex.synchronize do
95
- serializable = {}
96
- @store.each do |k, v|
97
- serializable[k] = v.respond_to?(:to_h) ? v.to_h : v
98
- end
99
- serializable
101
+ deep_json_safe(@store)
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ # Recursively convert a value to a JSON-safe structure.
108
+ def deep_json_safe(value)
109
+ case value
110
+ when Hash
111
+ value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_json_safe(v) }
112
+ when Array
113
+ value.map { |v| deep_json_safe(v) }
114
+ when Symbol
115
+ value.to_s
116
+ when String, Numeric, true, false, nil
117
+ value
118
+ else
119
+ value.respond_to?(:to_h) ? deep_json_safe(value.to_h) : value.to_s
100
120
  end
101
121
  end
102
122
  end
@@ -12,12 +12,21 @@ module Ask
12
12
 
13
13
  # Executes the declared steps of a graph, managing checkpointing,
14
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.
15
20
  class Runner
16
21
  # @return [Array<Hash>] step declarations with name, type, condition
17
22
  attr_reader :declarations
18
23
 
24
+ # @return [#set, #get, #delete] the checkpoint store
25
+ attr_reader :store
26
+
19
27
  # @param declarations [Array<Hash>] step declarations
20
- # @param checkpoint_store [#set, #get, #delete] optional persistent store
28
+ # @param checkpoint_store [#set, #get, #delete] key-value store.
29
+ # Defaults to in-memory (no durability across restarts).
21
30
  def initialize(declarations, checkpoint_store: nil)
22
31
  @declarations = declarations
23
32
  @store = checkpoint_store
@@ -28,7 +37,7 @@ module Ask
28
37
  # @param context [Ask::Graph::Context] the shared context
29
38
  # @return [Ask::Graph::Context] the completed context
30
39
  def run(context)
31
- resume_index = load_checkpoint
40
+ resume_index = load_checkpoint(context)
32
41
 
33
42
  @declarations.each_with_index do |decl, idx|
34
43
  next if resume_index && idx <= resume_index
@@ -40,7 +49,7 @@ module Ask
40
49
 
41
50
  execute_step(decl, context)
42
51
  record_completion(idx, :completed)
43
- save_checkpoint(idx)
52
+ save_checkpoint(context, idx)
44
53
  end
45
54
 
46
55
  context
@@ -60,7 +69,7 @@ module Ask
60
69
  # @return [Integer, nil] the index to resume from, or nil
61
70
  def resume_index_for(items)
62
71
  key = each_checkpoint_key
63
- raw = @store&.get(key)
72
+ raw = @store.get(key)
64
73
  raw ? raw.to_i : nil
65
74
  end
66
75
 
@@ -68,7 +77,7 @@ module Ask
68
77
  # @param index [Integer] the completed iteration index
69
78
  def checkpoint_each!(index)
70
79
  key = each_checkpoint_key
71
- @store&.set(key, index.to_s)
80
+ @store.set(key, index.to_s)
72
81
  end
73
82
 
74
83
  private
@@ -110,8 +119,7 @@ module Ask
110
119
  def run_approve(klass, _name, context)
111
120
  instance = klass.new
112
121
  instance.call(context)
113
- # After the step runs, pause and wait for human input
114
- save_checkpoint(current_index)
122
+ save_checkpoint(context, current_index)
115
123
  raise Paused, "Graph paused for approval after #{klass.name}"
116
124
  end
117
125
 
@@ -141,16 +149,28 @@ module Ask
141
149
  "#{checkpoint_key}:each"
142
150
  end
143
151
 
144
- def save_checkpoint(idx)
145
- data = { completed_index: idx, timestamp: Time.now.iso8601 }
146
- @store&.set(checkpoint_key, JSON.generate(data))
152
+ # Save checkpoint with full context state so resumed graphs
153
+ # have access to all data produced by completed steps.
154
+ def save_checkpoint(context, idx)
155
+ data = {
156
+ completed_index: idx,
157
+ context: context.to_h,
158
+ timestamp: Time.now.iso8601
159
+ }
160
+ @store.set(checkpoint_key, JSON.generate(data))
147
161
  end
148
162
 
149
- def load_checkpoint
150
- raw = @store&.get(checkpoint_key)
163
+ # Load checkpoint and restore context state if available.
164
+ # Returns the completed_index to skip, or nil for a fresh run.
165
+ def load_checkpoint(context)
166
+ raw = @store.get(checkpoint_key)
151
167
  return nil unless raw
152
168
 
153
169
  data = JSON.parse(raw)
170
+ restored = data["context"]
171
+ if restored.is_a?(Hash)
172
+ restored.each { |k, v| context[k.to_sym] = v }
173
+ end
154
174
  data["completed_index"]
155
175
  end
156
176
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  class Graph
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.1"
6
6
  end
7
7
  end
data/lib/ask/graph.rb CHANGED
@@ -16,28 +16,11 @@ module Ask
16
16
  # step BookAppointment
17
17
  # end
18
18
  #
19
- # @example With parallel steps
20
- # class SyncData < Ask::Graph
21
- # step FetchRecords
19
+ # result = HandleCall.new.call(recording: "call.wav")
22
20
  #
23
- # steps CrmUpdate, CalendarSync, SendNotification
24
- #
25
- # step ConfirmResponse
26
- # end
27
- #
28
- # @example With human-in-the-loop
29
- # class ProcessBooking < Ask::Graph
30
- # step BookAppointment
31
- # approve ReviewBooking, if: :expensive?
32
- # step ConfirmBooking
33
- # end
34
- #
35
- # @example With per-item looping
36
- # class SendReminders < Ask::Graph
37
- # step FetchAppointments
38
- # step RemindEach # calls context.each(:appointments) internally
39
- # step MarkComplete
40
- # end
21
+ # @example With checkpointing
22
+ # store = Ask::State::Memory.new
23
+ # result = HandleCall.new.call(input, checkpoint_store: store)
41
24
  #
42
25
  class Graph
43
26
  class << self
@@ -95,26 +78,22 @@ module Ask
95
78
  }
96
79
  end
97
80
 
98
- # Create a new instance and run the graph.
99
- # @param input [Object] optional initial input
81
+ # Convenience: create instance and call.
82
+ # @param input [Object] optional input
100
83
  # @param checkpoint_store [#set, #get, #delete] optional persistent store
101
84
  # @return [Ask::Graph::Context] the completed context
102
- def run(input = nil, checkpoint_store: nil)
103
- new(checkpoint_store: checkpoint_store).run(input)
104
- end
105
-
106
- # Resume a paused graph with human input.
107
- # @param input [Object] the human's input
108
- # @param checkpoint_store [#set, #get, #delete] the same store used for the original run
109
- # @return [Ask::Graph::Context] the completed context
110
- def resume(input:, checkpoint_store:)
111
- new(checkpoint_store: checkpoint_store).resume(input: input)
85
+ def call(input = nil, checkpoint_store: nil)
86
+ new(checkpoint_store: checkpoint_store).call(input)
112
87
  end
88
+ alias run call
113
89
  end
114
90
 
115
91
  # @param checkpoint_store [#set, #get, #delete, nil] optional persistent store
92
+ # Defaults to in-memory store (no durability). Pass a persistent store
93
+ # for crash recovery.
116
94
  def initialize(checkpoint_store: nil)
117
- @runner = Runner.new(self.class.declarations, checkpoint_store: checkpoint_store)
95
+ store = checkpoint_store || Ask::State::Memory.new
96
+ @runner = Runner.new(self.class.declarations, checkpoint_store: store)
118
97
  @context = nil
119
98
  end
120
99
 
@@ -127,14 +106,15 @@ module Ask
127
106
  # Run the graph with the given input.
128
107
  # @param input [Object] optional initial input
129
108
  # @return [Ask::Graph::Context] the completed context
130
- def run(input = nil)
109
+ def call(input = nil)
131
110
  @context = Context.new(self, input)
132
111
  @runner.run(@context)
133
112
  @context
134
113
  rescue => e
135
- raise unless e.class.name&.end_with?("Paused")
114
+ raise unless e.is_a?(Paused)
136
115
  @context
137
116
  end
117
+ alias run call
138
118
 
139
119
  # Resume a paused graph with human input.
140
120
  # @param input [Object] the human's input
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.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto