gaskit 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.
data/lib/gaskit/flow.rb CHANGED
@@ -4,224 +4,428 @@ require "securerandom"
4
4
  require_relative "core"
5
5
  require_relative "flow_result"
6
6
  require_relative "helpers"
7
+ require_relative "hookable"
7
8
 
8
9
  module Gaskit
9
- # Base class for defining and executing multi-step operation pipelines
10
+ # The `Gaskit::Flow` class defines and executes a pipeline of operations,
11
+ # each of which returns a `Gaskit::OperationResult`. Flows can be defined via
12
+ # a class-based DSL or run inline using a block.
13
+ #
14
+ # Steps share context and flow state, with support for early exits, manual
15
+ # control, and step-by-step walking. A flow is constructed from a sequence of
16
+ # registered operations, each of which may receive and return input to influence
17
+ # subsequent steps.
18
+ #
19
+ # ## Features
20
+ # - Declarative or block-based step definitions
21
+ # - Per-step argument and context overrides
22
+ # - Shared flow-level context with metadata injection
23
+ # - Manual stepping (`walk` and `next_step`)
24
+ # - Rewind capability for retryable flows
25
+ # - Hookable lifecycle via `Gaskit::Hookable`
10
26
  #
11
27
  # @example Inline (block-based) flow
12
- # result = Gaskit::Flow.call(1, 2, context: {}) do
13
- # step AddOp
14
- # step MultOp, multiplier: 2
28
+ # result = Gaskit::Flow.call(1) do
29
+ # step Add
30
+ # step Double
15
31
  # end
16
32
  #
33
+ # result.value # => 4
34
+ #
17
35
  # @example Class-based flow
18
36
  # class MyFlow < Gaskit::Flow
19
- # step AddOp
20
- # step MultOp, multiplier: 1.5
37
+ # step Add
38
+ # step Double
21
39
  # end
22
40
  #
23
- # result = MyFlow.call(5, 5, context: { request_id: "abc123" })
41
+ # result = MyFlow.call(1)
42
+ # result.value # => 4
43
+ #
44
+ # @example Step-by-step execution (walk)
45
+ # flow = MyFlow.walk(1)
46
+ # while flow.has_next_step?
47
+ # flow.next_step
48
+ # end
49
+ # flow.result.value # => 4
50
+ #
51
+ # @example Rewinding and re-running
52
+ # flow.rewind
53
+ # flow.next_step # starts from first step again
54
+ #
55
+ # @see Gaskit::Operation
56
+ # @see Gaskit::FlowResult
57
+ # @see Gaskit::Hookable
24
58
  class Flow
59
+ include Gaskit::Hookable
60
+
25
61
  class << self
26
- # Inherited hook to initialize step DSL
62
+ # Called when a subclass is defined, initializing an empty step list.
27
63
  #
28
- # @param subclass [Class] The subclass inheriting from Flow
64
+ # @param subclass [Class] the subclass inheriting from Flow
29
65
  # @return [void]
30
66
  def inherited(subclass)
31
- subclass.instance_variable_set(:@defined_steps, [])
67
+ subclass.instance_variable_set(:@steps, @steps)
32
68
  super
33
69
  end
34
70
 
35
- # Returns defined steps for the flow class
71
+ # Returns the list of declared steps for the flow class.
36
72
  #
37
- # @return [Array<Array>] An array of [operation, args, kwargs] tuples
38
- def defined_steps
39
- @defined_steps ||= []
73
+ # @return [Array<Array>] An array of [operation, context, kwargs] triples.
74
+ def steps
75
+ @steps ||= []
40
76
  end
41
77
 
42
- # Adds a step to the flow
78
+ # Registers a step in the class-level flow definition.
43
79
  #
44
- # @param operation [Class<Gaskit::Operation>] The operation class
45
- # @param args [Array] Positional arguments for the step
46
- # @param context [Hash] Optional context overrides
47
- # @param kwargs [Hash] Keyword arguments for the step
80
+ # @param operation [Class<Gaskit::Operation>] The operation to run.
81
+ # @param context [Hash] Optional context for this step.
82
+ # @param kwargs [Hash] Keyword arguments for this step.
48
83
  # @return [void]
49
- def step(operation, *args, context: {}, **kwargs)
50
- kwargs = kwargs.merge(context: context)
51
- defined_steps << [operation, args, kwargs]
84
+ def step(operation, context: {}, **kwargs)
85
+ steps << [operation, context, kwargs]
52
86
  end
53
87
 
54
- # Executes the flow with soft-failure handling
88
+ # Executes the flow with soft failure handling.
55
89
  #
56
- # @param args [Array] Positional arguments for the first step
57
- # @param context [Hash] Shared context across all steps
58
- # @param kwargs [Hash] Keyword arguments for the first step
59
- # @return [FlowResult]
90
+ # @param args [Array] Positional arguments for the first step.
91
+ # @param context [Hash] Shared context across all steps.
92
+ # @param kwargs [Hash] Keyword arguments for the first step.
93
+ # @return [FlowResult] The result of the flow execution.
60
94
  def call(*args, context: {}, **kwargs, &block)
61
95
  invoke(false, context, *args, **kwargs, &block)
62
96
  end
63
97
 
64
- # Executes the flow with hard-failure handling (raises on unhandled errors)
98
+ # Executes the flow with hard failure handling (raises on unhandled errors).
65
99
  #
66
- # @param args [Array] Positional arguments for the first step
67
- # @param context [Hash] Shared context across all steps
68
- # @param kwargs [Hash] Keyword arguments for the first step
69
- # @return [FlowResult]
100
+ # @param args [Array] Positional arguments for the first step.
101
+ # @param context [Hash] Shared context across all steps.
102
+ # @param kwargs [Hash] Keyword arguments for the first step.
103
+ # @return [FlowResult] The result of the flow execution.
104
+ # @raise [StandardError] If an error occurs in any step.
70
105
  def call!(*args, context: {}, **kwargs, &block)
71
106
  invoke(true, context, *args, **kwargs, &block)
72
107
  end
73
108
 
74
- private
109
+ # Creates a flow instance for step-by-step execution.
110
+ #
111
+ # @param args [Array] Initial positional arguments.
112
+ # @param context [Hash] Shared execution context.
113
+ # @param kwargs [Hash] Initial keyword arguments.
114
+ # @return [Flow] A walkable flow instance.
115
+ def walk(*args, context: {}, **kwargs)
116
+ build(false, context, *args, **kwargs)
117
+ end
75
118
 
76
- # Internal flow initializer
119
+ # Same as {#walk} but raises on any step failure.
120
+ #
121
+ # @param args [Array] Initial positional arguments.
122
+ # @param context [Hash] Shared execution context.
123
+ # @param kwargs [Hash] Initial keyword arguments.
124
+ # @return [Flow] A walkable flow instance with hard failure behavior.
125
+ def walk!(*args, context: {}, **kwargs)
126
+ build(true, context, *args, **kwargs)
127
+ end
128
+
129
+ # Internal flow execution logic.
130
+ #
131
+ # @param raise_on_failure [Boolean] Whether to raise on failure.
132
+ # @param context [Hash] Execution context.
133
+ # @param args [Array] Initial positional arguments.
134
+ # @param kwargs [Hash] Initial keyword arguments.
135
+ # @return [FlowResult] The result of the executed flow.
77
136
  def invoke(raise_on_failure, context, *args, **kwargs, &block)
78
- flow = new(raise_on_failure, context, [args, kwargs])
79
- flow.execute(&block)
137
+ flow = build(raise_on_failure, context, *args, **kwargs, &block)
138
+ flow.send(:execute, &block)
80
139
  end
81
- end
82
140
 
83
- # @return [Hash] Execution context
84
- attr_reader :context
141
+ private
85
142
 
86
- # @return [Gaskit::OperationResult] Most recent result
87
- attr_reader :result
143
+ # Constructs a flow instance.
144
+ #
145
+ # @param raise_on_failure [Boolean] Whether the flow should raise on failure.
146
+ # @param context [Hash] Flow context.
147
+ # @param args [Array] Positional args.
148
+ # @param kwargs [Hash] Keyword args.
149
+ # @return [Flow] The constructed flow instance.
150
+ def build(raise_on_failure, context, *args, **kwargs)
151
+ new(raise_on_failure, context, *args, **kwargs)
152
+ end
153
+ end
88
154
 
89
- # @return [Array<Hash>] List of step metadata
90
- attr_reader :steps
155
+ attr_reader :logger
91
156
 
92
- # Executes a single step of the flow
157
+ # Returns true if there are more steps remaining in the sequence.
93
158
  #
94
- # @param operation [Class<Gaskit::Operation>]
95
- # @param args [Array] Additional positional arguments
96
- # @param context [Hash] Step-local context
97
- # @param kwargs [Hash] Additional keyword arguments
98
- # @return [void]
99
- def step(operation, *args, context: {}, **kwargs, &block)
100
- raise ArgumentError, "Operation must be a subclass of Gaskit::Operation" unless operation <= Gaskit::Operation
159
+ # @return [Boolean] Whether the flow has more steps to execute.
160
+ def next_step?
161
+ @step_index < @step_sequence.size
162
+ end
101
163
 
102
- return if result&.early_exit?
164
+ # Returns the metadata for the pending step.
165
+ #
166
+ # @return [Hash, nil] The pending step's operation, context, and kwargs, or nil if no steps remain.
167
+ def pending_step(*args, **kwargs)
168
+ return nil unless next_step?
103
169
 
104
- kwargs = kwargs.merge(context: context)
105
- @result = execute_step(operation, *args, **kwargs, &block)
106
- @steps << compile_step_entry(operation, *args, **kwargs)
170
+ operation, context, step_kwargs = @step_sequence[@step_index]
171
+ context = @context.merge(context)
172
+ args, kwargs = resolve_step_input(
173
+ args: args,
174
+ kwargs: kwargs,
175
+ step_kwargs: step_kwargs
176
+ )
107
177
 
108
- update_input(result)
178
+ { operation: operation, context: context, args: args, kwargs: kwargs }
109
179
  end
110
180
 
111
- # Executes the flow either via block or pre-defined DSL
181
+ # Executes the next step in the flow.
112
182
  #
113
- # @return [FlowResult]
114
- def execute(&block)
115
- duration, = Gaskit::Helpers.time_execution do
116
- if block_given?
117
- instance_eval(&block)
118
- else
119
- self.class.defined_steps.each { |(op, args, kwargs)| step(op, *args, **kwargs) }
120
- end
183
+ # @param args [Array] Optional positional arguments to override input.
184
+ # @param kwargs [Hash] Optional keyword arguments to override input.
185
+ # @return [Gaskit::OperationResult, nil] The result of the step, or nil if no steps remain.
186
+ def next_step(*args, **kwargs)
187
+ return unless next_step?
121
188
 
122
- result
123
- end
189
+ operation, context, step_kwargs = @step_sequence[@step_index]
190
+ @step_index += 1
191
+
192
+ process_step(operation, context, step_kwargs, args, kwargs)
193
+ end
194
+
195
+ # Runs a step inline from within a block-based flow definition.
196
+ #
197
+ # @param operation [Class<Gaskit::Operation>] The operation to execute.
198
+ # @param context [Hash] Additional context for the step.
199
+ # @param kwargs [Hash] Keyword arguments for the step.
200
+ # @return [Gaskit::OperationResult] The result of the step.
201
+ def step(operation, context: {}, **kwargs)
202
+ process_step(operation, context, kwargs)
203
+ end
204
+
205
+ # Rewinds the flow to its initial state.
206
+ #
207
+ # @return [void]
208
+ def rewind
209
+ @input = @initial_input.dup
210
+ @result = nil
211
+ @steps.clear
212
+ @step_index = 0
213
+ end
124
214
 
125
- FlowResult.new(@result, @steps, duration: duration, context: @context)
215
+ # Returns the result hashes from all executed steps.
216
+ #
217
+ # @return [Array<Hash>] Array of step metadata hashes including input/output.
218
+ def results
219
+ @steps.map { |entry| entry[:result] }
126
220
  end
127
221
 
128
222
  private
129
223
 
130
- # Initializes a flow instance
224
+ # Initializes a new flow instance.
131
225
  #
132
- # @param raise_on_failure [Boolean] Whether to raise on unexpected errors
133
- # @param context [Hash] Flow context
134
- # @param input [Array] Initial args/kwargs input bundle
135
- def initialize(raise_on_failure, context, input)
226
+ # @param raise_on_failure [Boolean] Whether to raise on step failure.
227
+ # @param context [Hash] Flow execution context.
228
+ # @param args [Array] Initial positional arguments.
229
+ # @param kwargs [Hash] Initial keyword arguments.
230
+ def initialize(raise_on_failure, context, *args, **kwargs)
136
231
  @raise_on_failure = raise_on_failure
137
232
  @context = apply_context(context)
138
- @input = input
139
- @steps = []
233
+
234
+ @input = [args, kwargs]
235
+ @initial_input = @input.dup
140
236
  @result = nil
237
+
238
+ @steps = []
239
+ @step_sequence = self.class.steps.dup
240
+ @step_index = 0
241
+
242
+ @logger = Gaskit::Logger.new(self, context: @context)
141
243
  end
142
244
 
143
- # Applies global context, if set, from Gaskit.configuration.context_provider
144
- # and injects the `gaskit_flow` key to indicate to operations they are a part
145
- # of a flow.
245
+ # Executes a single step of the flow.
146
246
  #
147
- # @param context [Hash] The context provided directly to the Flow.
148
- # @return [Hash] The fully applied context Hash.
149
- def apply_context(context)
150
- default_context = Gaskit.configuration.context_provider.call
151
- context = default_context.merge(
152
- gaskit_flow: { id: SecureRandom.uuid, name: self.class.name },
153
- **context
247
+ # @param operation [Class<Gaskit::Operation>] The operation to execute.
248
+ # @param context [Hash] Step-local context.
249
+ # @param step_kwargs [Hash] DSL-defined kwargs.
250
+ # @param override_args [Array] Positional args from manual call.
251
+ # @param override_kwargs [Hash] Kwargs from manual call.
252
+ # @return [Gaskit::OperationResult] The result object.
253
+ def process_step(operation, context, step_kwargs, override_args = [], override_kwargs = {})
254
+ raise ArgumentError, "Operation must be a subclass of Gaskit::Operation" unless operation <= Gaskit::Operation
255
+ return if @result&.early_exit?
256
+
257
+ args, kwargs = resolve_step_input(
258
+ args: override_args,
259
+ kwargs: override_kwargs,
260
+ step_kwargs: step_kwargs
154
261
  )
155
262
 
156
- Helpers.deep_compact(context)
263
+ kwargs = kwargs.merge(context: context)
264
+
265
+ @result = execute_step(operation, context, args, kwargs)
266
+ @steps << step_entry(operation, args, kwargs)
267
+ @input = next_step_input || [[], {}]
268
+
269
+ @result
157
270
  end
158
271
 
159
- # Executes a single operation step and handles errors
272
+ # Resolves arguments for a step, merging flow input and overrides.
160
273
  #
161
- # @param operation [Class<Gaskit::Operation>]
162
- # @param kwargs [Hash] Merged args/kwargs/context
163
- # @return [Gaskit::OperationResult]
164
- def execute_step(operation, **kwargs, &block)
274
+ # @param args [Array] Overriding args.
275
+ # @param kwargs [Hash] Overriding kwargs.
276
+ # @param step_kwargs [Hash] Default step keyword args.
277
+ # @return [Array<Array, Hash>] Final [args, kwargs] pair.
278
+ def resolve_step_input(args: [], kwargs: {}, step_kwargs: {})
165
279
  input_args, input_kwargs = @input
166
- kwargs = (input_kwargs || {}).merge(kwargs).merge(context: @context)
167
280
 
168
- return operation.call!(*input_args, **kwargs, &block) if @raise_on_failure
281
+ args = input_args if args.empty?
282
+ kwargs = input_kwargs.merge(step_kwargs).merge(kwargs)
283
+
284
+ [args, kwargs]
285
+ end
169
286
 
170
- operation.call(*input_args, **kwargs, &block)
171
- rescue StandardError => e
172
- raise e if @raise_on_failure
287
+ # Executes all steps in the flow.
288
+ #
289
+ # @return [FlowResult] The result of the executed flow.
290
+ def execute(&block)
291
+ duration, (_, error) = time_execution(&block)
292
+ result = build_result(duration, error)
173
293
 
174
- result_class = operation.class.result_class
175
- result_class.new(false, nil, e, duration: 0.0, context: @context)
294
+ begin
295
+ apply_after_hooks(result)
296
+ rescue StandardError => e
297
+ result = handle_after_hook_error(e, duration)
298
+ end
299
+
300
+ result
301
+ end
302
+
303
+ # Executes a specific operation step.
304
+ #
305
+ # @param operation [Class<Gaskit::Operation>] The operation to run.
306
+ # @param context [Hash] Execution context for the operation.
307
+ # @param args [Array] Positional arguments.
308
+ # @param kwargs [Hash] Keyword arguments.
309
+ # @return [Gaskit::OperationResult] Result of the operation call.
310
+ def execute_step(operation, context, args, kwargs, &block)
311
+ raise ArgumentError, "Operation must be a subclass of Gaskit::Operation" unless operation <= Gaskit::Operation
312
+
313
+ context = @context.merge(context)
314
+ return operation.call!(*args, context: context, **kwargs, &block) if @raise_on_failure
315
+
316
+ operation.call(*args, context: context, **kwargs, &block)
176
317
  end
177
318
 
178
- # Logs a step’s full input and output
319
+ # Times flow execution, including any hooks set.
179
320
  #
180
- # @param operation [Class]
181
- # @param args [Array]
182
- # @param kwargs [Hash]
183
- # @return [Hash] Step metadata
184
- def compile_step_entry(operation, *args, **kwargs)
185
- args, kwargs = step_input(*args, **kwargs)
321
+ # @return [Array<Float, Object>] Execution time and final result.
322
+ def time_execution(&block)
323
+ Helpers.time_execution do
324
+ apply_hooks(:before, :around) do
325
+ if block_given?
326
+ instance_eval(&block)
327
+ else
328
+ @step_sequence.each { next_step }
329
+ end
330
+
331
+ [@result, nil]
332
+ end
333
+ rescue StandardError => e
334
+ handle_execution_error(e)
335
+ [nil, e]
336
+ end
337
+ end
186
338
 
339
+ # Merges default and passed context and injects flow metadata.
340
+ #
341
+ # @param context [Hash] User-provided context.
342
+ # @return [Hash] Final context.
343
+ def apply_context(context)
344
+ default_context = Gaskit.configuration.context_provider.call
345
+ context = default_context.merge(
346
+ gaskit_flow: { id: SecureRandom.uuid, name: Gaskit::Helpers.resolve_name(self) },
347
+ **context
348
+ )
349
+
350
+ Helpers.deep_compact(context)
351
+ end
352
+
353
+ # Builds a step metadata entry.
354
+ #
355
+ # @param operation [Class] Operation class.
356
+ # @param args [Array] Arguments passed.
357
+ # @param kwargs [Hash] Keyword arguments passed.
358
+ # @return [Hash] Metadata about the step.
359
+ def step_entry(operation, args, kwargs)
187
360
  {
188
361
  operation: operation,
189
362
  args: args,
190
363
  kwargs: kwargs,
191
- result: result.to_h
364
+ result: @result.to_h
192
365
  }
193
366
  end
194
367
 
195
- # Combines current flow input with explicit args for logging
368
+ # Determines next input tuple based on previous result.
196
369
  #
197
- # @param args [Array]
198
- # @param kwargs [Hash]
199
- # @return [Array<Array, Hash>]
200
- def step_input(*args, **kwargs)
201
- input_args, input_kwargs = @input
202
- args = (input_args || []).concat(args)
203
- kwargs = input_kwargs.merge(kwargs)
370
+ # @return [Array<Array, Hash>, nil] Tuple of [args, kwargs] or nil if step failed.
371
+ def next_step_input
372
+ case @result&.value
373
+ when Array
374
+ [@result&.value, {}]
375
+ when Hash
376
+ [[], @result&.value]
377
+ else
378
+ [[@result&.value], {}]
379
+ end
380
+ end
204
381
 
205
- [args, kwargs]
382
+ # Builds a FlowResult object.
383
+ #
384
+ # @param duration [Float] Time spent executing.
385
+ # @param error [StandardError, nil] Optional error object.
386
+ # @return [FlowResult] The constructed result object.
387
+ def build_result(duration, error = nil)
388
+ error ||= @result&.error
389
+
390
+ FlowResult.new(
391
+ error.nil?,
392
+ @result&.value,
393
+ error,
394
+ steps: @steps,
395
+ duration: duration,
396
+ context: @context
397
+ )
206
398
  end
207
399
 
208
- # Set the input used to call the next operation. Do not set input if the result
209
- # is a failure or has a nil value.
400
+ # Handles a raised exception during execution.
210
401
  #
211
- # @param result [Gaskit::OperationResult] The result of the operation.
402
+ # @param error [StandardError] The raised error.
212
403
  # @return [void]
213
- def update_input(result)
214
- return if result&.failure? || result&.value.nil?
215
-
216
- @input =
217
- case result.value
218
- when Array
219
- [result.value, {}]
220
- when Hash
221
- [[], result.value]
222
- else
223
- [[result.value], {}]
224
- end
404
+ # @raise [StandardError] If raise_on_failure is true.
405
+ def handle_execution_error(error)
406
+ log_exception(error)
407
+ raise error if @raise_on_failure
408
+ end
409
+
410
+ # Handles a post-hook exception.
411
+ #
412
+ # @param error [StandardError] The raised error.
413
+ # @param duration [Float] Total flow duration.
414
+ # @return [FlowResult] A failed FlowResult.
415
+ def handle_after_hook_error(error, duration)
416
+ log_exception(error)
417
+ raise error if @raise_on_failure
418
+
419
+ build_result(duration, error)
420
+ end
421
+
422
+ # Logs an exception with context.
423
+ #
424
+ # @param exception [StandardError] Exception to log.
425
+ # @return [void]
426
+ def log_exception(exception)
427
+ logger.error { "[#{exception.class}] #{exception.message}" }
428
+ # logger.error { exception.backtrace&.join("\n") }
225
429
  end
226
430
  end
227
431
  end
@@ -15,21 +15,28 @@ module Gaskit
15
15
  attr_reader :steps
16
16
 
17
17
  # Initializes a new FlowResult
18
- #
19
- # @param result [Gaskit::OperationResult] The final operation result
20
- # @param steps [Array<Hash>] Step-by-step execution details
21
- # @param duration [Float, String] Total flow duration
22
- # @param context [Hash] Execution context
23
- def initialize(result, steps, duration:, context: {})
18
+ # .
19
+ # @param success [Boolean] If the flow was successful or not
20
+ # @param value [Object, nil] The final operation result
21
+ # @param error [StandardError, nil] The error encountered during the operation.
22
+ # @param options [Hash] Keyword arguments
23
+ # @option options [Array<Hash>] :steps Step-by-step execution details
24
+ # @option options [Float, String] :duration Total flow duration
25
+ # @option options [Hash] Execution context
26
+ def initialize(success, value, error = nil, **options)
24
27
  super(
25
- result.success?,
26
- result.value,
27
- result.error,
28
- duration: duration,
29
- context: context
28
+ success,
29
+ value,
30
+ error,
31
+ duration: options[:duration],
32
+ context: options[:context]
30
33
  )
31
34
 
32
- @steps = steps
35
+ @steps = options.fetch(:steps, [])
36
+ end
37
+
38
+ def to_h
39
+ super.merge(steps: steps)
33
40
  end
34
41
  end
35
42
  end
@@ -25,6 +25,21 @@ module Gaskit
25
25
  result[k.to_sym] = compacted unless compacted.nil?
26
26
  end
27
27
  end
28
+
29
+ # Resolves the provide class's name.
30
+ #
31
+ # @param source [Class, Object, String, Symbol]
32
+ # @return [String] The resolved class name.
33
+ def resolve_name(source)
34
+ case source
35
+ when String, Symbol
36
+ source.to_s
37
+ when Class
38
+ source.name
39
+ else
40
+ source.class.name
41
+ end
42
+ end
28
43
  end
29
44
  end
30
45
  end