langgraph_rb 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.
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/langgraph_rb'
4
+ require 'logger'
5
+
6
+ # Custom observer for metrics collection
7
+ class MetricsObserver < LangGraphRB::Observers::BaseObserver
8
+ def initialize(metrics_client)
9
+ @metrics = metrics_client
10
+ end
11
+
12
+ def on_node_end(event)
13
+ @metrics.histogram('node.duration', event.duration * 1000, {
14
+ node_name: event.node_name.to_s,
15
+ node_type: event.node_class&.name
16
+ })
17
+ end
18
+
19
+ def on_node_error(event)
20
+ @metrics.increment('node.error', {
21
+ node_name: event.node_name.to_s,
22
+ error_type: event.error&.class&.name
23
+ })
24
+ end
25
+ end
26
+
27
+ # Database observer for audit trails
28
+ class DatabaseObserver < LangGraphRB::Observers::BaseObserver
29
+ def initialize(db_connection)
30
+ @db = db_connection
31
+ end
32
+
33
+ def on_graph_start(event)
34
+ @db.execute(
35
+ "INSERT INTO graph_executions (thread_id, graph_class, started_at) VALUES (?, ?, ?)",
36
+ event.thread_id, event.graph.class.name, event.timestamp
37
+ )
38
+ end
39
+
40
+ def on_node_end(event)
41
+ @db.execute(
42
+ "INSERT INTO node_executions (thread_id, step_number, node_name, duration_ms, completed_at) VALUES (?, ?, ?, ?, ?)",
43
+ event.thread_id, event.step_number, event.node_name.to_s, (event.duration * 1000).round(2), event.timestamp
44
+ )
45
+ end
46
+ end
47
+
48
+ class MyMetricsClient
49
+ def histogram(name, value, tags)
50
+ puts "Histogram: #{name}, #{value}, #{tags}"
51
+ end
52
+
53
+ def increment(name, tags)
54
+ puts "Increment: #{name}, #{tags}"
55
+ end
56
+ end
57
+
58
+ class MyDBConnection
59
+ def execute(query, *args)
60
+ puts "Executing: #{query}, #{args}"
61
+ end
62
+ end
63
+
64
+ def observer_example
65
+ puts "=== Observer Example ==="
66
+
67
+ # Create a simple graph for demonstration
68
+ initial_state = LangGraphRB::State.new(
69
+ { message: "", messages: [], response: "" }
70
+ )
71
+
72
+ # Create the graph using DSL
73
+ graph = LangGraphRB::Graph.new(state_class: LangGraphRB::State) do
74
+ # Simple node that processes messages
75
+ node :process_message do |state|
76
+ puts "Processing: #{state[:message]}"
77
+ {
78
+ messages: state[:messages] + [state[:message]],
79
+ response: "Processed: #{state[:message]}"
80
+ }
81
+ end
82
+
83
+ # Set up the flow
84
+ set_entry_point :process_message
85
+ set_finish_point :process_message
86
+ end
87
+
88
+ # Compile the graph
89
+ graph.compile!
90
+
91
+ # Basic logging observability
92
+ logger_observer = LangGraphRB::Observers::LoggerObserver.new(
93
+ logger: Logger.new('graph_execution.log'),
94
+ level: :info
95
+ )
96
+
97
+ puts "\n--- Running with basic logging observer ---"
98
+ graph.invoke(
99
+ { message: "Hello World", messages: [], response: "" },
100
+ observers: [logger_observer]
101
+ )
102
+
103
+ my_metrics_client = MyMetricsClient.new
104
+ my_db_connection = MyDBConnection.new
105
+
106
+ # Use multiple observers
107
+ puts "\n--- Running with multiple observers ---"
108
+ graph.invoke(
109
+ { message: "Testing multiple observers", messages: [], response: "" },
110
+ observers: [
111
+ logger_observer,
112
+ MetricsObserver.new(my_metrics_client),
113
+ DatabaseObserver.new(my_db_connection)
114
+ ]
115
+ )
116
+
117
+ puts "\nObserver example completed! Check 'graph_execution.log' for logged events."
118
+ end
119
+
120
+ # Run the example
121
+ observer_example if __FILE__ == $0
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/langgraph_rb'
4
+
5
+ # Custom state class for workflow example
6
+ class UserSetupState < LangGraphRB::State
7
+ def initialize(schema = {}, reducers = nil)
8
+ # If reducers are not provided, use our default reducers
9
+ reducers ||= { user_info: LangGraphRB::State.merge_hash }
10
+ super(schema, reducers)
11
+ end
12
+ end
13
+
14
+ # Example 1: WITHOUT Reducers - Simple Replacement Behavior
15
+ def without_reducers_example
16
+ puts "\n" + "=" * 60
17
+ puts "🔴 EXAMPLE 1: WITHOUT REDUCERS (Simple Replacement)"
18
+ puts "=" * 60
19
+
20
+ # Create state without any reducers - everything is simple replacement
21
+ state = LangGraphRB::State.new({
22
+ counter: 0,
23
+ messages: [],
24
+ user_data: { name: "Alice" }
25
+ })
26
+
27
+ puts "Initial state:"
28
+ puts " Counter: #{state[:counter]}"
29
+ puts " Messages: #{state[:messages]}"
30
+ puts " User data: #{state[:user_data]}"
31
+
32
+ # First update - this will REPLACE the values completely
33
+ puts "\n📝 First update: { counter: 5, messages: ['Hello'], user_data: { age: 25 } }"
34
+ state = state.merge_delta({
35
+ counter: 5,
36
+ messages: ['Hello'],
37
+ user_data: { age: 25 }
38
+ })
39
+
40
+ puts "After first update:"
41
+ puts " Counter: #{state[:counter]} ← REPLACED with 5"
42
+ puts " Messages: #{state[:messages]} ← REPLACED with ['Hello']"
43
+ puts " User data: #{state[:user_data]} ← REPLACED with { age: 25 } (name is LOST!)"
44
+
45
+ # Second update - again, simple replacement
46
+ puts "\n📝 Second update: { counter: 3, messages: ['World'], user_data: { city: 'NYC' } }"
47
+ state = state.merge_delta({
48
+ counter: 3,
49
+ messages: ['World'],
50
+ user_data: { city: 'NYC' }
51
+ })
52
+
53
+ puts "After second update:"
54
+ puts " Counter: #{state[:counter]} ← REPLACED with 3 (lost the 5!)"
55
+ puts " Messages: #{state[:messages]} ← REPLACED with ['World'] (lost 'Hello'!)"
56
+ puts " User data: #{state[:user_data]} ← REPLACED with { city: 'NYC' } (lost age!)"
57
+
58
+ puts "\n❌ PROBLEM: Without reducers, we lose previous data on every update!"
59
+ end
60
+
61
+ # Example 2: WITH Reducers - Intelligent Merging
62
+ def with_reducers_example
63
+ puts "\n" + "=" * 60
64
+ puts "🟢 EXAMPLE 2: WITH REDUCERS (Intelligent Merging)"
65
+ puts "=" * 60
66
+
67
+ # Create state WITH reducers that define how to combine values
68
+ state = LangGraphRB::State.new(
69
+ {
70
+ counter: 0,
71
+ messages: [],
72
+ user_data: { name: "Alice" }
73
+ },
74
+ {
75
+ counter: ->(old, new) { (old || 0) + new }, # ADD numbers
76
+ messages: LangGraphRB::State.add_messages, # APPEND to array
77
+ user_data: LangGraphRB::State.merge_hash # MERGE hashes
78
+ }
79
+ )
80
+
81
+ puts "Initial state:"
82
+ puts " Counter: #{state[:counter]}"
83
+ puts " Messages: #{state[:messages]}"
84
+ puts " User data: #{state[:user_data]}"
85
+
86
+ # First update - reducers will intelligently combine values
87
+ puts "\n📝 First update: { counter: 5, messages: ['Hello'], user_data: { age: 25 } }"
88
+ state = state.merge_delta({
89
+ counter: 5,
90
+ messages: ['Hello'],
91
+ user_data: { age: 25 }
92
+ })
93
+
94
+ puts "After first update:"
95
+ puts " Counter: #{state[:counter]} ← ADDED: 0 + 5 = 5"
96
+ puts " Messages: #{state[:messages]} ← APPENDED: [] + ['Hello'] = ['Hello']"
97
+ puts " User data: #{state[:user_data]} ← MERGED: {name: 'Alice'} + {age: 25}"
98
+
99
+ # Second update - reducers continue to combine intelligently
100
+ puts "\n📝 Second update: { counter: 3, messages: ['World'], user_data: { city: 'NYC' } }"
101
+ state = state.merge_delta({
102
+ counter: 3,
103
+ messages: ['World'],
104
+ user_data: { city: 'NYC' }
105
+ })
106
+
107
+ puts "After second update:"
108
+ puts " Counter: #{state[:counter]} ← ADDED: 5 + 3 = 8"
109
+ puts " Messages: #{state[:messages]} ← APPENDED: ['Hello'] + ['World']"
110
+ puts " User data: #{state[:user_data]} ← MERGED: keeps all previous data!"
111
+
112
+ puts "\n✅ SUCCESS: With reducers, we intelligently combine data instead of losing it!"
113
+ end
114
+
115
+ # Example 3: Built-in Reducers Demonstration
116
+ def builtin_reducers_example
117
+ puts "\n" + "=" * 60
118
+ puts "🛠️ EXAMPLE 3: BUILT-IN REDUCERS"
119
+ puts "=" * 60
120
+
121
+ # Demonstrate each built-in reducer
122
+ puts "📌 1. add_messages - For building conversation history"
123
+ messages_state = LangGraphRB::State.new(
124
+ { messages: [] },
125
+ { messages: LangGraphRB::State.add_messages }
126
+ )
127
+
128
+ messages_state = messages_state.merge_delta({ messages: { role: 'user', content: 'Hi!' } })
129
+ messages_state = messages_state.merge_delta({ messages: [{ role: 'assistant', content: 'Hello!' }] })
130
+ messages_state = messages_state.merge_delta({ messages: { role: 'user', content: 'How are you?' } })
131
+
132
+ puts "Messages history: #{messages_state[:messages]}"
133
+
134
+ puts "\n📌 2. append_string - For building text content"
135
+ text_state = LangGraphRB::State.new(
136
+ { story: "" },
137
+ { story: LangGraphRB::State.append_string }
138
+ )
139
+
140
+ text_state = text_state.merge_delta({ story: "Once upon a time, " })
141
+ text_state = text_state.merge_delta({ story: "there was a brave knight " })
142
+ text_state = text_state.merge_delta({ story: "who saved the kingdom." })
143
+
144
+ puts "Story: \"#{text_state[:story]}\""
145
+
146
+ puts "\n📌 3. merge_hash - For building complex objects"
147
+ profile_state = LangGraphRB::State.new(
148
+ { profile: {} },
149
+ { profile: LangGraphRB::State.merge_hash }
150
+ )
151
+
152
+ profile_state = profile_state.merge_delta({ profile: { name: "Bob" } })
153
+ profile_state = profile_state.merge_delta({ profile: { age: 30, city: "Boston" } })
154
+ profile_state = profile_state.merge_delta({ profile: { job: "Developer", age: 31 } }) # age updated
155
+
156
+ puts "Profile: #{profile_state[:profile]}"
157
+ end
158
+
159
+ # Example 4: Custom Reducers
160
+ def custom_reducers_example
161
+ puts "\n" + "=" * 60
162
+ puts "⚙️ EXAMPLE 4: CUSTOM REDUCERS"
163
+ puts "=" * 60
164
+
165
+ # Custom reducer: Keep maximum value
166
+ max_reducer = ->(old, new) { [old || 0, new || 0].max }
167
+
168
+ # Custom reducer: Keep unique items in array
169
+ unique_array_reducer = ->(old, new) do
170
+ old_array = old || []
171
+ new_items = new.is_a?(Array) ? new : [new]
172
+ (old_array + new_items).uniq
173
+ end
174
+
175
+ # Custom reducer: Track changes with timestamps
176
+ history_reducer = ->(old, new) do
177
+ old_history = old || []
178
+ timestamp = Time.now.strftime("%H:%M:%S")
179
+ old_history + [{ value: new, timestamp: timestamp }]
180
+ end
181
+
182
+ state = LangGraphRB::State.new(
183
+ {
184
+ max_score: 0,
185
+ unique_tags: [],
186
+ value_history: []
187
+ },
188
+ {
189
+ max_score: max_reducer,
190
+ unique_tags: unique_array_reducer,
191
+ value_history: history_reducer
192
+ }
193
+ )
194
+
195
+ puts "📊 Testing custom reducers..."
196
+
197
+ # Test updates
198
+ state = state.merge_delta({ max_score: 85, unique_tags: ['ruby', 'programming'], value_history: 'first' })
199
+ puts "After update 1:"
200
+ puts " Max score: #{state[:max_score]}"
201
+ puts " Unique tags: #{state[:unique_tags]}"
202
+ puts " History: #{state[:value_history]}"
203
+
204
+ sleep(1) # Small delay to show different timestamps
205
+
206
+ state = state.merge_delta({ max_score: 72, unique_tags: ['ruby', 'web', 'api'], value_history: 'second' })
207
+ puts "\nAfter update 2:"
208
+ puts " Max score: #{state[:max_score]} ← Kept the higher value (85)"
209
+ puts " Unique tags: #{state[:unique_tags]} ← Added new unique tags"
210
+ puts " History: #{state[:value_history]} ← Tracked all changes with timestamps"
211
+
212
+ sleep(1)
213
+
214
+ state = state.merge_delta({ max_score: 95, unique_tags: ['programming'], value_history: 'third' })
215
+ puts "\nAfter update 3:"
216
+ puts " Max score: #{state[:max_score]} ← New maximum!"
217
+ puts " Unique tags: #{state[:unique_tags]} ← No duplicates added"
218
+ puts " History: #{state[:value_history]} ← Complete change history"
219
+ end
220
+
221
+ # Example 5: Real-world Graph Workflow Comparison
222
+ def workflow_comparison_example
223
+ puts "\n" + "=" * 60
224
+ puts "🚀 EXAMPLE 5: REAL-WORLD WORKFLOW COMPARISON"
225
+ puts "=" * 60
226
+
227
+ puts "🔴 WITHOUT Reducers - Data Loss Problem:"
228
+
229
+ # Workflow without reducers
230
+ graph_without = LangGraphRB::Graph.new do
231
+ node :collect_user_info do |state|
232
+ { user_info: { name: state[:name] } }
233
+ end
234
+
235
+ node :collect_preferences do |state|
236
+ { user_info: { theme: state[:theme] } } # This OVERWRITES name!
237
+ end
238
+
239
+ node :finalize do |state|
240
+ { result: "User setup complete: #{state[:user_info]}" }
241
+ end
242
+
243
+ set_entry_point :collect_user_info
244
+ edge :collect_user_info, :collect_preferences
245
+ edge :collect_preferences, :finalize
246
+ set_finish_point :finalize
247
+ end
248
+
249
+ graph_without.compile!
250
+ result_without = graph_without.invoke({ name: "Alice", theme: "dark" })
251
+ puts "❌ Result: #{result_without[:result]} ← Lost the name!"
252
+
253
+ puts "\n🟢 WITH Reducers - Data Preservation:"
254
+
255
+ # Workflow with reducers
256
+ graph_with = LangGraphRB::Graph.new(state_class: UserSetupState) do
257
+ node :collect_user_info do |state|
258
+ { user_info: { name: state[:name] } }
259
+ end
260
+
261
+ node :collect_preferences do |state|
262
+ { user_info: { theme: state[:theme] } } # This MERGES with name!
263
+ end
264
+
265
+ node :finalize do |state|
266
+ { result: "User setup complete: #{state[:user_info]}" }
267
+ end
268
+
269
+ set_entry_point :collect_user_info
270
+ edge :collect_user_info, :collect_preferences
271
+ edge :collect_preferences, :finalize
272
+ set_finish_point :finalize
273
+ end
274
+
275
+ graph_with.compile!
276
+ result_with = graph_with.invoke({ name: "Alice", theme: "dark" })
277
+ puts "✅ Result: #{result_with[:result]} ← Preserved all data!"
278
+ end
279
+
280
+ # Example 6: Performance and Memory Considerations
281
+ def performance_example
282
+ puts "\n" + "=" * 60
283
+ puts "⚡ EXAMPLE 6: PERFORMANCE CONSIDERATIONS"
284
+ puts "=" * 60
285
+
286
+ puts "🔍 Memory efficiency with reducers:"
287
+
288
+ # Show how reducers create new state objects (immutable)
289
+ original_state = LangGraphRB::State.new(
290
+ { data: [1, 2, 3] },
291
+ { data: LangGraphRB::State.add_messages }
292
+ )
293
+
294
+ puts "Original state object_id: #{original_state.object_id}"
295
+ puts "Original data object_id: #{original_state[:data].object_id}"
296
+
297
+ new_state = original_state.merge_delta({ data: [4, 5] })
298
+
299
+ puts "New state object_id: #{new_state.object_id} ← Different object (immutable)"
300
+ puts "New data object_id: #{new_state[:data].object_id} ← New array object"
301
+ puts "Original data unchanged: #{original_state[:data]} ← Still [1, 2, 3]"
302
+ puts "New data: #{new_state[:data]} ← Combined result [1, 2, 3, 4, 5]"
303
+
304
+ puts "\n💡 Key Benefits:"
305
+ puts " • Immutable updates (thread-safe)"
306
+ puts " • Predictable state changes"
307
+ puts " • Easy to debug and test"
308
+ puts " • Prevents accidental data loss"
309
+ end
310
+
311
+ # Run all examples
312
+ def run_all_examples
313
+ puts "🧪 LangGraphRB State Reducers - Complete Tutorial"
314
+ puts "=" * 80
315
+
316
+ without_reducers_example
317
+ with_reducers_example
318
+ builtin_reducers_example
319
+ custom_reducers_example
320
+ workflow_comparison_example
321
+ performance_example
322
+
323
+ puts "\n" + "=" * 80
324
+ puts "🎯 KEY TAKEAWAYS:"
325
+ puts "1. Without reducers: Simple replacement (data loss risk)"
326
+ puts "2. With reducers: Intelligent merging (data preservation)"
327
+ puts "3. Built-in reducers: add_messages, append_string, merge_hash"
328
+ puts "4. Custom reducers: Define your own combination logic"
329
+ puts "5. Graph workflows: Reducers prevent data loss between nodes"
330
+ puts "6. Immutable updates: Thread-safe and predictable"
331
+ puts "=" * 80
332
+ end
333
+
334
+ # Run the complete tutorial
335
+ if __FILE__ == $0
336
+ run_all_examples
337
+ end
@@ -96,7 +96,7 @@ module LangGraphRB
96
96
  end
97
97
 
98
98
  # Execute the graph synchronously
99
- def invoke(input_state = {}, context: nil, store: nil, thread_id: nil)
99
+ def invoke(input_state = {}, context: nil, store: nil, thread_id: nil, observers: [])
100
100
  raise GraphError, "Graph must be compiled before execution" unless compiled?
101
101
 
102
102
  store ||= Stores::InMemoryStore.new
@@ -104,11 +104,12 @@ module LangGraphRB
104
104
 
105
105
  initial_state = @state_class.new(input_state)
106
106
 
107
- Runner.new(self, store: store, thread_id: thread_id).invoke(initial_state, context: context)
107
+ Runner.new(self, store: store, thread_id: thread_id, observers: observers)
108
+ .invoke(initial_state, context: context)
108
109
  end
109
110
 
110
111
  # Stream execution results
111
- def stream(input_state = {}, context: nil, store: nil, thread_id: nil)
112
+ def stream(input_state = {}, context: nil, store: nil, thread_id: nil, observers: [])
112
113
  raise GraphError, "Graph must be compiled before execution" unless compiled?
113
114
 
114
115
  store ||= Stores::InMemoryStore.new
@@ -116,7 +117,8 @@ module LangGraphRB
116
117
 
117
118
  initial_state = @state_class.new(input_state)
118
119
 
119
- Runner.new(self, store: store, thread_id: thread_id).stream(initial_state, context: context)
120
+ Runner.new(self, store: store, thread_id: thread_id, observers: observers)
121
+ .stream(initial_state, context: context)
120
122
  end
121
123
 
122
124
  # Resume execution from a checkpoint
@@ -0,0 +1,188 @@
1
+ require 'json'
2
+ require 'time'
3
+
4
+ module LangGraphRB
5
+ module Observers
6
+ # Abstract base class for observability implementations
7
+ class BaseObserver
8
+ # Called when graph execution starts
9
+ def on_graph_start(event)
10
+ # Override in subclasses
11
+ end
12
+
13
+ # Called when graph execution completes
14
+ def on_graph_end(event)
15
+ # Override in subclasses
16
+ end
17
+
18
+ # Called when a node execution starts
19
+ def on_node_start(event)
20
+ # Override in subclasses
21
+ end
22
+
23
+ # Called when a node execution completes successfully
24
+ def on_node_end(event)
25
+ # Override in subclasses
26
+ end
27
+
28
+ # Called when a node execution encounters an error
29
+ def on_node_error(event)
30
+ # Override in subclasses
31
+ end
32
+
33
+ # Called when a step completes (multiple nodes may execute in parallel)
34
+ def on_step_complete(event)
35
+ # Override in subclasses
36
+ end
37
+
38
+ # Called when state changes occur
39
+ def on_state_change(event)
40
+ # Override in subclasses
41
+ end
42
+
43
+ # Called when commands are processed (Send, Command, etc.)
44
+ def on_command_processed(event)
45
+ # Override in subclasses
46
+ end
47
+
48
+ # Called when interrupts occur
49
+ def on_interrupt(event)
50
+ # Override in subclasses
51
+ end
52
+
53
+ # Called when checkpoints are saved
54
+ def on_checkpoint_saved(event)
55
+ # Override in subclasses
56
+ end
57
+
58
+ # Shutdown hook for cleanup
59
+ def shutdown
60
+ # Override in subclasses if cleanup needed
61
+ end
62
+
63
+ protected
64
+
65
+ # Helper method to create standardized event structure
66
+ def create_event(type, data = {})
67
+ {
68
+ type: type,
69
+ timestamp: Time.now.utc.iso8601,
70
+ thread_id: data[:thread_id],
71
+ step_number: data[:step_number],
72
+ **data
73
+ }
74
+ end
75
+
76
+ # Helper to sanitize state data (remove sensitive info, limit size)
77
+ def sanitize_state(state, max_size: 1000)
78
+ return nil unless state
79
+
80
+ state_hash = state.respond_to?(:to_h) ? state.to_h : state
81
+ serialized = state_hash.to_json
82
+
83
+ if serialized.length > max_size
84
+ { _truncated: true, _size: serialized.length, _preview: serialized[0...max_size] }
85
+ else
86
+ state_hash
87
+ end
88
+ end
89
+ end
90
+
91
+ # Event data structures
92
+ class GraphEvent
93
+ attr_reader :type, :graph, :initial_state, :context, :thread_id, :timestamp
94
+
95
+ def initialize(type:, graph:, initial_state: nil, context: nil, thread_id: nil)
96
+ @type = type
97
+ @graph = graph
98
+ @initial_state = initial_state
99
+ @context = context
100
+ @thread_id = thread_id
101
+ @timestamp = Time.now.utc
102
+ end
103
+
104
+ def to_h
105
+ {
106
+ type: @type,
107
+ graph_class: @graph.class.name,
108
+ node_count: @graph.nodes.size,
109
+ edge_count: @graph.edges.size,
110
+ initial_state: @initial_state,
111
+ context: @context,
112
+ thread_id: @thread_id,
113
+ timestamp: @timestamp.iso8601
114
+ }
115
+ end
116
+ end
117
+
118
+ class NodeEvent
119
+ attr_reader :type, :node_name, :node_class, :state_before, :state_after,
120
+ :context, :thread_id, :step_number, :duration, :error, :result, :timestamp
121
+
122
+ def initialize(type:, node_name:, node_class: nil, state_before: nil, state_after: nil,
123
+ context: nil, thread_id: nil, step_number: nil, duration: nil,
124
+ error: nil, result: nil)
125
+ @type = type
126
+ @node_name = node_name
127
+ @node_class = node_class
128
+ @state_before = state_before
129
+ @state_after = state_after
130
+ @context = context
131
+ @thread_id = thread_id
132
+ @step_number = step_number
133
+ @duration = duration
134
+ @error = error
135
+ @result = result
136
+ @timestamp = Time.now.utc
137
+ end
138
+
139
+ def to_h
140
+ {
141
+ type: @type,
142
+ node_name: @node_name,
143
+ node_class: @node_class&.name,
144
+ state_before: @state_before,
145
+ state_after: @state_after,
146
+ context: @context,
147
+ thread_id: @thread_id,
148
+ step_number: @step_number,
149
+ duration_ms: @duration ? (@duration * 1000).round(2) : nil,
150
+ error: @error&.message,
151
+ error_class: @error&.class&.name,
152
+ result_type: @result&.class&.name,
153
+ timestamp: @timestamp.iso8601
154
+ }
155
+ end
156
+ end
157
+
158
+ class StepEvent
159
+ attr_reader :type, :step_number, :active_nodes, :completed_nodes, :thread_id,
160
+ :state, :duration, :timestamp
161
+
162
+ def initialize(type:, step_number:, active_nodes: [], completed_nodes: [],
163
+ thread_id: nil, state: nil, duration: nil)
164
+ @type = type
165
+ @step_number = step_number
166
+ @active_nodes = active_nodes
167
+ @completed_nodes = completed_nodes
168
+ @thread_id = thread_id
169
+ @state = state
170
+ @duration = duration
171
+ @timestamp = Time.now.utc
172
+ end
173
+
174
+ def to_h
175
+ {
176
+ type: @type,
177
+ step_number: @step_number,
178
+ active_nodes: @active_nodes,
179
+ completed_nodes: @completed_nodes,
180
+ thread_id: @thread_id,
181
+ state: @state,
182
+ duration_ms: @duration ? (@duration * 1000).round(2) : nil,
183
+ timestamp: @timestamp.iso8601
184
+ }
185
+ end
186
+ end
187
+ end
188
+ end