From bc3077e3443b0cc234069a3d67ab1b5ad74d8bb5 Mon Sep 17 00:00:00 2001 From: Santiago Bartesaghi Date: Sun, 1 Mar 2026 13:18:06 -0300 Subject: [PATCH 1/4] Gate executes branches directly + class-level DSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the Mars v2 refactor. - Gate: add class-level `condition`/`branch` DSL for reusable gates. Gate#run now executes the matched branch directly instead of returning a Runnable for Sequential to detect - Aggregator: context-aware — accepts ExecutionContext and passes its outputs to the operation - Sequential: remove is_a?(Runnable) check, now just chains step results Co-Authored-By: Claude Opus 4.6 --- lib/mars/gate.rb | 31 +++++++- lib/mars/workflows/sequential.rb | 9 +-- spec/mars/aggregator_spec.rb | 6 +- spec/mars/gate_spec.rb | 132 ++++++++++++++++++++----------- 4 files changed, 116 insertions(+), 62 deletions(-) diff --git a/lib/mars/gate.rb b/lib/mars/gate.rb index 8a2bafd..e416ab8 100644 --- a/lib/mars/gate.rb +++ b/lib/mars/gate.rb @@ -2,21 +2,44 @@ module MARS class Gate < Runnable - def initialize(name = "Gate", condition:, branches:, **kwargs) + class << self + def condition(&block) + @condition_block = block + end + + attr_reader :condition_block + + def branch(key, runnable) + branches_map[key] = runnable + end + + def branches_map + @branches_map ||= {} + end + end + + def initialize(name = "Gate", condition: nil, branches: nil, **kwargs) super(name: name, **kwargs) - @condition = condition - @branches = branches + @condition = condition || self.class.condition_block + @branches = branches || self.class.branches_map end def run(input) result = condition.call(input) + branch = branches[result] + + return input unless branch - branches[result] || input + resolve_branch(branch).run(input) end private attr_reader :condition, :branches + + def resolve_branch(branch) + branch.is_a?(Class) ? branch.new : branch + end end end diff --git a/lib/mars/workflows/sequential.rb b/lib/mars/workflows/sequential.rb index df673c6..fadae2e 100644 --- a/lib/mars/workflows/sequential.rb +++ b/lib/mars/workflows/sequential.rb @@ -11,14 +11,7 @@ def initialize(name, steps:, **kwargs) def run(input) @steps.each do |step| - result = step.run(input) - - if result.is_a?(Runnable) - input = result.run(input) - break - else - input = result - end + input = step.run(input) end input diff --git a/spec/mars/aggregator_spec.rb b/spec/mars/aggregator_spec.rb index 294ec93..408803e 100644 --- a/spec/mars/aggregator_spec.rb +++ b/spec/mars/aggregator_spec.rb @@ -2,7 +2,7 @@ RSpec.describe MARS::Aggregator do describe "#run" do - context "when called without a block" do + context "when called without an operation" do let(:aggregator) { described_class.new } it "returns the input as is" do @@ -11,10 +11,10 @@ end end - context "when initialized with a block operation" do + context "when initialized with an operation" do let(:aggregator) { described_class.new("Aggregator", operation: lambda(&:join)) } - it "executes the block and returns its value" do + it "executes the operation and returns its value" do result = aggregator.run(%w[a b c]) expect(result).to eq("abc") end diff --git a/spec/mars/gate_spec.rb b/spec/mars/gate_spec.rb index d8cf33d..0990e58 100644 --- a/spec/mars/gate_spec.rb +++ b/spec/mars/gate_spec.rb @@ -2,82 +2,120 @@ RSpec.describe MARS::Gate do describe "#run" do - let(:gate) { described_class.new("TestGate", condition: condition, branches: branches) } + context "with constructor-based configuration" do + let(:short_step) do + Class.new(MARS::Runnable) do + def run(input) + "short: #{input}" + end + end.new + end - context "with simple boolean condition" do - let(:condition) { ->(input) { input > 5 } } - let(:false_branch) { instance_spy(MARS::Runnable) } - let(:branches) { { false => false_branch } } + let(:long_step) do + Class.new(MARS::Runnable) do + def run(input) + "long: #{input}" + end + end.new + end - it "returns the input when no branch matches" do - result = gate.run(10) - expect(result).to eq(10) + let(:gate) do + described_class.new( + "LengthGate", + condition: ->(input) { input.length > 5 ? :long : :short }, + branches: { short: short_step, long: long_step } + ) end - it "returns the false branch when condition is false" do - result = gate.run(3) + it "executes the matched branch directly" do + expect(gate.run("hi")).to eq("short: hi") + end - expect(result).to eq(false_branch) + it "executes the other branch for different input" do + expect(gate.run("longstring")).to eq("long: longstring") end - it "does not run the false branch when condition is false" do - gate.run(3) + it "returns input when no branch matches" do + gate = described_class.new( + "NoMatch", + condition: ->(_input) { :unknown }, + branches: { short: short_step } + ) - expect(false_branch).not_to have_received(:run) + expect(gate.run("hello")).to eq("hello") end end - context "with string-based condition" do - let(:condition) { ->(input) { input.length > 5 ? "long" : "short" } } - let(:long_branch) { instance_spy(MARS::Runnable) } - let(:short_branch) { instance_spy(MARS::Runnable) } - let(:branches) { { "long" => long_branch, "short" => short_branch } } - - it "routes to long branch for long strings" do - result = gate.run("longstring") + context "with class-level DSL" do + let(:short_step_class) do + Class.new(MARS::Runnable) do + def run(input) + "quick: #{input}" + end + end + end - expect(result).to eq(long_branch) + let(:long_step_class) do + Class.new(MARS::Runnable) do + def run(input) + "deep: #{input}" + end + end end - it "routes to short branch for short strings" do - result = gate.run("hi") + it "uses condition and branch DSL" do + short_cls = short_step_class + long_cls = long_step_class - expect(result).to eq(short_branch) + gate_class = Class.new(described_class) do + condition { |input| input.length < 5 ? :short : :long } + branch :short, short_cls + branch :long, long_cls + end + + gate = gate_class.new("DSLGate") + expect(gate.run("hi")).to eq("quick: hi") + expect(gate.run("longstring")).to eq("deep: longstring") end end context "with complex condition logic" do - let(:condition) do - lambda do |input| - case input - when 0..10 then "low" - when 11..50 then "medium" - else "high" - end - end + let(:low_step) do + Class.new(MARS::Runnable) { def run(input) = "low:#{input}" }.new end - let(:low_branch) { instance_spy(MARS::Runnable) } - let(:medium_branch) { instance_spy(MARS::Runnable) } - let(:high_branch) { instance_spy(MARS::Runnable) } - let(:branches) { { "low" => low_branch, "medium" => medium_branch, "high" => high_branch } } + let(:medium_step) do + Class.new(MARS::Runnable) { def run(input) = "med:#{input}" }.new + end - it "routes to low branch" do - result = gate.run(5) + let(:high_step) do + Class.new(MARS::Runnable) { def run(input) = "high:#{input}" }.new + end - expect(result).to eq(low_branch) + let(:gate) do + described_class.new( + "SeverityGate", + condition: lambda { |input| + case input + when 0..10 then :low + when 11..50 then :medium + else :high + end + }, + branches: { low: low_step, medium: medium_step, high: high_step } + ) end - it "routes to medium branch" do - result = gate.run(25) + it "routes to low branch" do + expect(gate.run(5)).to eq("low:5") + end - expect(result).to eq(medium_branch) + it "routes to medium branch" do + expect(gate.run(25)).to eq("med:25") end it "routes to high branch" do - result = gate.run(100) - - expect(result).to eq(high_branch) + expect(gate.run(100)).to eq("high:100") end end end From 51c654221eeb8e5d370bbe0588056d86566cddac Mon Sep 17 00:00:00 2001 From: Santiago Bartesaghi Date: Wed, 4 Mar 2026 12:34:04 -0300 Subject: [PATCH 2/4] Address review comments --- lib/mars/gate.rb | 2 +- lib/mars/halt.rb | 11 +++++++++++ lib/mars/workflows/parallel.rb | 4 +++- lib/mars/workflows/sequential.rb | 5 +++++ spec/mars/gate_spec.rb | 20 ++++++++++++-------- spec/mars/workflows/parallel_spec.rb | 21 +++++++++++++++++++++ spec/mars/workflows/sequential_spec.rb | 21 +++++++++++++++++++++ 7 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 lib/mars/halt.rb diff --git a/lib/mars/gate.rb b/lib/mars/gate.rb index e416ab8..361cf7f 100644 --- a/lib/mars/gate.rb +++ b/lib/mars/gate.rb @@ -31,7 +31,7 @@ def run(input) return input unless branch - resolve_branch(branch).run(input) + Halt.new(resolve_branch(branch).run(input)) end private diff --git a/lib/mars/halt.rb b/lib/mars/halt.rb new file mode 100644 index 0000000..c140a5f --- /dev/null +++ b/lib/mars/halt.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module MARS + class Halt + attr_reader :result + + def initialize(result) + @result = result + end + end +end diff --git a/lib/mars/workflows/parallel.rb b/lib/mars/workflows/parallel.rb index ef8f3f6..52bca71 100644 --- a/lib/mars/workflows/parallel.rb +++ b/lib/mars/workflows/parallel.rb @@ -26,7 +26,9 @@ def run(input) raise AggregateError, errors if errors.any? - aggregator.run(results) + has_halt = results.any? { |r| r.is_a?(Halt) } + result = aggregator.run(results) + has_halt ? Halt.new(result) : result end private diff --git a/lib/mars/workflows/sequential.rb b/lib/mars/workflows/sequential.rb index fadae2e..f33d616 100644 --- a/lib/mars/workflows/sequential.rb +++ b/lib/mars/workflows/sequential.rb @@ -12,6 +12,11 @@ def initialize(name, steps:, **kwargs) def run(input) @steps.each do |step| input = step.run(input) + + if input.is_a?(Halt) + input = input.result + break + end end input diff --git a/spec/mars/gate_spec.rb b/spec/mars/gate_spec.rb index 0990e58..39af878 100644 --- a/spec/mars/gate_spec.rb +++ b/spec/mars/gate_spec.rb @@ -27,12 +27,16 @@ def run(input) ) end - it "executes the matched branch directly" do - expect(gate.run("hi")).to eq("short: hi") + it "returns a Halt wrapping the branch result" do + result = gate.run("hi") + expect(result).to be_a(MARS::Halt) + expect(result.result).to eq("short: hi") end it "executes the other branch for different input" do - expect(gate.run("longstring")).to eq("long: longstring") + result = gate.run("longstring") + expect(result).to be_a(MARS::Halt) + expect(result.result).to eq("long: longstring") end it "returns input when no branch matches" do @@ -74,8 +78,8 @@ def run(input) end gate = gate_class.new("DSLGate") - expect(gate.run("hi")).to eq("quick: hi") - expect(gate.run("longstring")).to eq("deep: longstring") + expect(gate.run("hi").result).to eq("quick: hi") + expect(gate.run("longstring").result).to eq("deep: longstring") end end @@ -107,15 +111,15 @@ def run(input) end it "routes to low branch" do - expect(gate.run(5)).to eq("low:5") + expect(gate.run(5).result).to eq("low:5") end it "routes to medium branch" do - expect(gate.run(25)).to eq("med:25") + expect(gate.run(25).result).to eq("med:25") end it "routes to high branch" do - expect(gate.run(100)).to eq("high:100") + expect(gate.run(100).result).to eq("high:100") end end end diff --git a/spec/mars/workflows/parallel_spec.rb b/spec/mars/workflows/parallel_spec.rb index ab05d25..d142508 100644 --- a/spec/mars/workflows/parallel_spec.rb +++ b/spec/mars/workflows/parallel_spec.rb @@ -77,6 +77,27 @@ def run(_input) expect(workflow.run(42)).to eq([]) end + it "propagates Halt to parent workflow when a step halts" do + gate = MARS::Gate.new( + "AlwaysBranch", + condition: ->(_input) { :branch }, + branches: { + branch: Class.new(MARS::Runnable) { + def run(input) + "branched:#{input}" + end + }.new + } + ) + add_five = add_step_class.new(5) + + workflow = described_class.new("halt_workflow", steps: [gate, add_five]) + + result = workflow.run(10) + # Aggregator runs on all results, but output is wrapped in Halt + expect(result).to be_a(MARS::Halt) + end + it "propagates errors from steps" do add_step = add_step_class.new(5) error_step = error_step_class.new("Step failed", "error_step_one") diff --git a/spec/mars/workflows/sequential_spec.rb b/spec/mars/workflows/sequential_spec.rb index 45c0783..e53609c 100644 --- a/spec/mars/workflows/sequential_spec.rb +++ b/spec/mars/workflows/sequential_spec.rb @@ -62,6 +62,27 @@ def run(_input) expect(workflow.run(42)).to eq(42) end + it "halts when a step returns a Halt" do + add_five = add_step_class.new(5) + gate = MARS::Gate.new( + "AlwaysBranch", + condition: ->(_input) { :branch }, + branches: { + branch: Class.new(MARS::Runnable) { + def run(input) + "branched:#{input}" + end + }.new + } + ) + multiply_three = multiply_step_class.new(3) + + workflow = described_class.new("halt_workflow", steps: [add_five, gate, multiply_three]) + + # 10 + 5 = 15, gate branches -> "branched:15", multiply_three is never reached + expect(workflow.run(10)).to eq("branched:15") + end + it "propagates errors from steps" do add_step = add_step_class.new(5) error_step = error_step_class.new("Step failed") From 0985e9386f6e72bf9d2b4818203b02a4ad00f497 Mon Sep 17 00:00:00 2001 From: Santiago Bartesaghi Date: Wed, 4 Mar 2026 12:41:23 -0300 Subject: [PATCH 3/4] Fix linters --- lib/mars/workflows/parallel.rb | 28 +++++++++++++++----------- spec/mars/workflows/parallel_spec.rb | 4 ++-- spec/mars/workflows/sequential_spec.rb | 4 ++-- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/lib/mars/workflows/parallel.rb b/lib/mars/workflows/parallel.rb index 52bca71..caaa0e2 100644 --- a/lib/mars/workflows/parallel.rb +++ b/lib/mars/workflows/parallel.rb @@ -12,21 +12,11 @@ def initialize(name, steps:, aggregator: nil, **kwargs) def run(input) errors = [] - results = Async do |workflow| - tasks = @steps.map do |step| - workflow.async do - step.run(input) - rescue StandardError => e - errors << { error: e, step_name: step.name } - end - end - - tasks.map(&:wait) - end.result + results = execute_steps(input, errors) raise AggregateError, errors if errors.any? - has_halt = results.any? { |r| r.is_a?(Halt) } + has_halt = results.any?(Halt) result = aggregator.run(results) has_halt ? Halt.new(result) : result end @@ -34,6 +24,20 @@ def run(input) private attr_reader :steps, :aggregator + + def execute_steps(input, errors) + Async do |workflow| + tasks = steps.map do |step| + workflow.async do + step.run(input) + rescue StandardError => e + errors << { error: e, step_name: step.name } + end + end + + tasks.map(&:wait) + end.result + end end end end diff --git a/spec/mars/workflows/parallel_spec.rb b/spec/mars/workflows/parallel_spec.rb index d142508..bd2daf6 100644 --- a/spec/mars/workflows/parallel_spec.rb +++ b/spec/mars/workflows/parallel_spec.rb @@ -82,11 +82,11 @@ def run(_input) "AlwaysBranch", condition: ->(_input) { :branch }, branches: { - branch: Class.new(MARS::Runnable) { + branch: Class.new(MARS::Runnable) do def run(input) "branched:#{input}" end - }.new + end.new } ) add_five = add_step_class.new(5) diff --git a/spec/mars/workflows/sequential_spec.rb b/spec/mars/workflows/sequential_spec.rb index e53609c..0a23575 100644 --- a/spec/mars/workflows/sequential_spec.rb +++ b/spec/mars/workflows/sequential_spec.rb @@ -68,11 +68,11 @@ def run(_input) "AlwaysBranch", condition: ->(_input) { :branch }, branches: { - branch: Class.new(MARS::Runnable) { + branch: Class.new(MARS::Runnable) do def run(input) "branched:#{input}" end - }.new + end.new } ) multiply_three = multiply_step_class.new(3) From 489d44402c5f18d7192c52e001db6c467deabb2d Mon Sep 17 00:00:00 2001 From: Santiago Bartesaghi Date: Wed, 4 Mar 2026 14:10:06 -0300 Subject: [PATCH 4/4] Change Halt behavior --- README.md | 20 ++- examples/complex_llm_workflow/generator.rb | 4 +- examples/complex_workflow/generator.rb | 4 +- examples/simple_workflow/generator.rb | 4 +- lib/mars/gate.rb | 37 +++-- lib/mars/halt.rb | 8 +- lib/mars/rendering/graph/gate.rb | 4 +- lib/mars/workflows/parallel.rb | 7 +- lib/mars/workflows/sequential.rb | 10 +- spec/mars/gate_spec.rb | 162 +++++++++++---------- spec/mars/halt_spec.rb | 26 ++++ spec/mars/workflows/parallel_spec.rb | 34 ++++- spec/mars/workflows/sequential_spec.rb | 91 +++++++++++- 13 files changed, 284 insertions(+), 127 deletions(-) create mode 100644 spec/mars/halt_spec.rb diff --git a/README.md b/README.md index b39a6ba..48e9f20 100644 --- a/README.md +++ b/README.md @@ -120,19 +120,29 @@ parallel = MARS::Workflows::Parallel.new( ### Gates -Create conditional branching in your workflows: +Gates act as guards that either let the workflow continue or divert to a fallback path: ```ruby gate = MARS::Gate.new( - "Decision Gate", - condition: ->(input) { input[:score] > 0.5 ? :success : :failure }, - branches: { - success: success_workflow, + "Validation Gate", + check: ->(input) { :failure unless input[:score] > 0.5 }, + fallbacks: { failure: failure_workflow } ) ``` +Control halt scope — `:local` (default) stops only the parent workflow, `:global` propagates to the root: + +```ruby +gate = MARS::Gate.new( + "Critical Gate", + check: ->(input) { :error unless input[:valid] }, + fallbacks: { error: error_workflow }, + halt_scope: :global +) +``` + ### Visualization Generate Mermaid diagrams to visualize your workflows: diff --git a/examples/complex_llm_workflow/generator.rb b/examples/complex_llm_workflow/generator.rb index 4ea2c77..26d2e62 100755 --- a/examples/complex_llm_workflow/generator.rb +++ b/examples/complex_llm_workflow/generator.rb @@ -91,8 +91,8 @@ def tools ) gate = MARS::Gate.new( - condition: ->(input) { input.split.length < 10 ? :success : :failure }, - branches: { + check: ->(input) { :failure unless input.split.length < 10 }, + fallbacks: { failure: error_workflow } ) diff --git a/examples/complex_workflow/generator.rb b/examples/complex_workflow/generator.rb index 62adc6b..dc54fc6 100755 --- a/examples/complex_workflow/generator.rb +++ b/examples/complex_workflow/generator.rb @@ -46,8 +46,8 @@ class Agent5 < MARS::Agent # Create the gate that decides between exit or continue gate = MARS::Gate.new( - condition: ->(input) { input[:result] }, - branches: { + check: ->(input) { input[:result] }, + fallbacks: { warning: sequential_workflow, error: parallel_workflow } diff --git a/examples/simple_workflow/generator.rb b/examples/simple_workflow/generator.rb index 4dd01c4..2f8242c 100755 --- a/examples/simple_workflow/generator.rb +++ b/examples/simple_workflow/generator.rb @@ -26,8 +26,8 @@ class Agent3 < MARS::Agent # Create the gate that decides between exit or continue gate = MARS::Gate.new( - condition: ->(input) { input[:result] }, - branches: { + check: ->(input) { input[:result] }, + fallbacks: { success: success_workflow } ) diff --git a/lib/mars/gate.rb b/lib/mars/gate.rb index 361cf7f..5ce7502 100644 --- a/lib/mars/gate.rb +++ b/lib/mars/gate.rb @@ -3,40 +3,47 @@ module MARS class Gate < Runnable class << self - def condition(&block) - @condition_block = block + def check(&block) + @check_block = block end - attr_reader :condition_block + attr_reader :check_block - def branch(key, runnable) - branches_map[key] = runnable + def fallback(key, runnable) + fallbacks_map[key] = runnable end - def branches_map - @branches_map ||= {} + def fallbacks_map + @fallbacks_map ||= {} + end + + def halt_scope(scope = nil) + scope ? @halt_scope = scope : @halt_scope end end - def initialize(name = "Gate", condition: nil, branches: nil, **kwargs) + def initialize(name = "Gate", check: nil, fallbacks: nil, halt_scope: nil, **kwargs) super(name: name, **kwargs) - @condition = condition || self.class.condition_block - @branches = branches || self.class.branches_map + @check = check || self.class.check_block + @fallbacks = fallbacks || self.class.fallbacks_map + @halt_scope = halt_scope || self.class.halt_scope || :local end def run(input) - result = condition.call(input) - branch = branches[result] + result = check.call(input) + + return input unless result - return input unless branch + branch = fallbacks[result] + raise ArgumentError, "No fallback registered for #{result.inspect}" unless branch - Halt.new(resolve_branch(branch).run(input)) + Halt.new(resolve_branch(branch).run(input), scope: @halt_scope) end private - attr_reader :condition, :branches + attr_reader :check, :fallbacks def resolve_branch(branch) branch.is_a?(Class) ? branch.new : branch diff --git a/lib/mars/halt.rb b/lib/mars/halt.rb index c140a5f..043e80e 100644 --- a/lib/mars/halt.rb +++ b/lib/mars/halt.rb @@ -2,10 +2,14 @@ module MARS class Halt - attr_reader :result + attr_reader :result, :scope - def initialize(result) + def initialize(result, scope: :local) @result = result + @scope = scope end + + def local? = scope == :local + def global? = scope == :global end end diff --git a/lib/mars/rendering/graph/gate.rb b/lib/mars/rendering/graph/gate.rb index 2defd66..ccc5099 100644 --- a/lib/mars/rendering/graph/gate.rb +++ b/lib/mars/rendering/graph/gate.rb @@ -10,8 +10,8 @@ def to_graph(builder, parent_id: nil, value: nil) builder.add_node(node_id, name, Node::GATE) builder.add_edge(parent_id, node_id, value) - sink_nodes = branches.map do |condition_result, branch| - branch.to_graph(builder, parent_id: node_id, value: condition_result) + sink_nodes = fallbacks.map do |fallback_key, branch| + branch.to_graph(builder, parent_id: node_id, value: fallback_key) end sink_nodes.flatten diff --git a/lib/mars/workflows/parallel.rb b/lib/mars/workflows/parallel.rb index caaa0e2..99667cc 100644 --- a/lib/mars/workflows/parallel.rb +++ b/lib/mars/workflows/parallel.rb @@ -16,9 +16,10 @@ def run(input) raise AggregateError, errors if errors.any? - has_halt = results.any?(Halt) - result = aggregator.run(results) - has_halt ? Halt.new(result) : result + has_global_halt = results.any? { |r| r.is_a?(Halt) && r.global? } + unwrapped = results.map { |r| r.is_a?(Halt) ? r.result : r } + result = aggregator.run(unwrapped) + has_global_halt ? Halt.new(result, scope: :global) : result end private diff --git a/lib/mars/workflows/sequential.rb b/lib/mars/workflows/sequential.rb index f33d616..9f7a6ed 100644 --- a/lib/mars/workflows/sequential.rb +++ b/lib/mars/workflows/sequential.rb @@ -13,10 +13,12 @@ def run(input) @steps.each do |step| input = step.run(input) - if input.is_a?(Halt) - input = input.result - break - end + next unless input.is_a?(Halt) + + return input if input.global? + + input = input.result + break end input diff --git a/spec/mars/gate_spec.rb b/spec/mars/gate_spec.rb index 39af878..cea8849 100644 --- a/spec/mars/gate_spec.rb +++ b/spec/mars/gate_spec.rb @@ -1,125 +1,127 @@ # frozen_string_literal: true RSpec.describe MARS::Gate do - describe "#run" do - context "with constructor-based configuration" do - let(:short_step) do - Class.new(MARS::Runnable) do - def run(input) - "short: #{input}" - end - end.new + let(:fallback_step) do + Class.new(MARS::Runnable) do + def run(input) + "fallback: #{input}" end + end.new + end - let(:long_step) do - Class.new(MARS::Runnable) do - def run(input) - "long: #{input}" - end - end.new + let(:error_step) do + Class.new(MARS::Runnable) do + def run(input) + "error: #{input}" end + end.new + end - let(:gate) do - described_class.new( - "LengthGate", - condition: ->(input) { input.length > 5 ? :long : :short }, - branches: { short: short_step, long: long_step } + describe "#run" do + context "with constructor-based configuration" do + it "passes through when check returns falsy" do + gate = described_class.new( + "PassGate", + check: ->(_input) {}, + fallbacks: { fail: fallback_step } ) + + expect(gate.run("hello")).to eq("hello") end - it "returns a Halt wrapping the branch result" do - result = gate.run("hi") + it "halts with fallback result when check returns a key" do + gate = described_class.new( + "FailGate", + check: ->(_input) { :fail }, + fallbacks: { fail: fallback_step } + ) + + result = gate.run("hello") expect(result).to be_a(MARS::Halt) - expect(result.result).to eq("short: hi") + expect(result.result).to eq("fallback: hello") end - it "executes the other branch for different input" do - result = gate.run("longstring") - expect(result).to be_a(MARS::Halt) - expect(result.result).to eq("long: longstring") + it "raises when check returns an unregistered key" do + gate = described_class.new( + "BadGate", + check: ->(_input) { :unknown }, + fallbacks: { fail: fallback_step } + ) + + expect { gate.run("hello") }.to raise_error(ArgumentError, /No fallback registered for :unknown/) end - it "returns input when no branch matches" do + it "selects among multiple fallbacks" do gate = described_class.new( - "NoMatch", - condition: ->(_input) { :unknown }, - branches: { short: short_step } + "MultiFallback", + check: ->(input) { input[:error_type] }, + fallbacks: { timeout: fallback_step, auth: error_step } ) - expect(gate.run("hello")).to eq("hello") + input = { error_type: :auth } + result = gate.run(input) + expect(result).to be_a(MARS::Halt) + expect(result.result).to eq("error: #{input}") end end context "with class-level DSL" do - let(:short_step_class) do + let(:fallback_cls) do Class.new(MARS::Runnable) do def run(input) - "quick: #{input}" + "handled: #{input}" end end end - let(:long_step_class) do - Class.new(MARS::Runnable) do - def run(input) - "deep: #{input}" - end - end - end - - it "uses condition and branch DSL" do - short_cls = short_step_class - long_cls = long_step_class - + it "uses check and fallback DSL" do + cls = fallback_cls gate_class = Class.new(described_class) do - condition { |input| input.length < 5 ? :short : :long } - branch :short, short_cls - branch :long, long_cls + check { |input| :invalid if input.length > 5 } + fallback :invalid, cls end gate = gate_class.new("DSLGate") - expect(gate.run("hi").result).to eq("quick: hi") - expect(gate.run("longstring").result).to eq("deep: longstring") - end - end - - context "with complex condition logic" do - let(:low_step) do - Class.new(MARS::Runnable) { def run(input) = "low:#{input}" }.new + expect(gate.run("hi")).to eq("hi") + expect(gate.run("longstring").result).to eq("handled: longstring") end - let(:medium_step) do - Class.new(MARS::Runnable) { def run(input) = "med:#{input}" }.new - end + it "supports halt_scope DSL" do + cls = fallback_cls + gate_class = Class.new(described_class) do + check { |_input| :fail } + fallback :fail, cls + halt_scope :global + end - let(:high_step) do - Class.new(MARS::Runnable) { def run(input) = "high:#{input}" }.new + result = gate_class.new("GlobalGate").run("test") + expect(result).to be_a(MARS::Halt) + expect(result).to be_global end + end - let(:gate) do - described_class.new( - "SeverityGate", - condition: lambda { |input| - case input - when 0..10 then :low - when 11..50 then :medium - else :high - end - }, - branches: { low: low_step, medium: medium_step, high: high_step } + context "with halt scope" do + it "defaults to local scope" do + gate = described_class.new( + "LocalGate", + check: ->(_input) { :fail }, + fallbacks: { fail: fallback_step } ) - end - it "routes to low branch" do - expect(gate.run(5).result).to eq("low:5") + result = gate.run("hello") + expect(result).to be_local end - it "routes to medium branch" do - expect(gate.run(25).result).to eq("med:25") - end + it "respects constructor halt_scope" do + gate = described_class.new( + "GlobalGate", + check: ->(_input) { :fail }, + fallbacks: { fail: fallback_step }, + halt_scope: :global + ) - it "routes to high branch" do - expect(gate.run(100).result).to eq("high:100") + result = gate.run("hello") + expect(result).to be_global end end end diff --git a/spec/mars/halt_spec.rb b/spec/mars/halt_spec.rb new file mode 100644 index 0000000..da3b9c5 --- /dev/null +++ b/spec/mars/halt_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +RSpec.describe MARS::Halt do + describe "#scope" do + it "defaults to :local" do + halt = described_class.new("result") + expect(halt.scope).to eq(:local) + expect(halt).to be_local + expect(halt).not_to be_global + end + + it "can be set to :global" do + halt = described_class.new("result", scope: :global) + expect(halt.scope).to eq(:global) + expect(halt).to be_global + expect(halt).not_to be_local + end + end + + describe "#result" do + it "stores the result" do + halt = described_class.new("hello") + expect(halt.result).to eq("hello") + end + end +end diff --git a/spec/mars/workflows/parallel_spec.rb b/spec/mars/workflows/parallel_spec.rb index bd2daf6..e0bcb38 100644 --- a/spec/mars/workflows/parallel_spec.rb +++ b/spec/mars/workflows/parallel_spec.rb @@ -77,11 +77,11 @@ def run(_input) expect(workflow.run(42)).to eq([]) end - it "propagates Halt to parent workflow when a step halts" do + it "unwraps local halts and returns plain result" do gate = MARS::Gate.new( - "AlwaysBranch", - condition: ->(_input) { :branch }, - branches: { + "LocalBranch", + check: ->(_input) { :branch }, + fallbacks: { branch: Class.new(MARS::Runnable) do def run(input) "branched:#{input}" @@ -94,8 +94,32 @@ def run(input) workflow = described_class.new("halt_workflow", steps: [gate, add_five]) result = workflow.run(10) - # Aggregator runs on all results, but output is wrapped in Halt + # Local halts are unwrapped, aggregated as plain values + expect(result).not_to be_a(MARS::Halt) + expect(result).to eq(["branched:10", 15]) + end + + it "propagates global halt to parent workflow" do + gate = MARS::Gate.new( + "GlobalBranch", + check: ->(_input) { :branch }, + fallbacks: { + branch: Class.new(MARS::Runnable) do + def run(input) + "branched:#{input}" + end + end.new + }, + halt_scope: :global + ) + add_five = add_step_class.new(5) + + workflow = described_class.new("halt_workflow", steps: [gate, add_five]) + + result = workflow.run(10) expect(result).to be_a(MARS::Halt) + expect(result).to be_global + expect(result.result).to eq(["branched:10", 15]) end it "propagates errors from steps" do diff --git a/spec/mars/workflows/sequential_spec.rb b/spec/mars/workflows/sequential_spec.rb index 0a23575..01c3e44 100644 --- a/spec/mars/workflows/sequential_spec.rb +++ b/spec/mars/workflows/sequential_spec.rb @@ -62,12 +62,12 @@ def run(_input) expect(workflow.run(42)).to eq(42) end - it "halts when a step returns a Halt" do + it "halts locally when a gate triggers with local scope" do add_five = add_step_class.new(5) gate = MARS::Gate.new( - "AlwaysBranch", - condition: ->(_input) { :branch }, - branches: { + "LocalGate", + check: ->(_input) { :branch }, + fallbacks: { branch: Class.new(MARS::Runnable) do def run(input) "branched:#{input}" @@ -80,7 +80,88 @@ def run(input) workflow = described_class.new("halt_workflow", steps: [add_five, gate, multiply_three]) # 10 + 5 = 15, gate branches -> "branched:15", multiply_three is never reached - expect(workflow.run(10)).to eq("branched:15") + # Local halt is consumed — returns plain value + result = workflow.run(10) + expect(result).to eq("branched:15") + expect(result).not_to be_a(MARS::Halt) + end + + it "propagates global halt without unwrapping" do + add_five = add_step_class.new(5) + gate = MARS::Gate.new( + "GlobalGate", + check: ->(_input) { :branch }, + fallbacks: { + branch: Class.new(MARS::Runnable) do + def run(input) + "branched:#{input}" + end + end.new + }, + halt_scope: :global + ) + multiply_three = multiply_step_class.new(3) + + workflow = described_class.new("halt_workflow", steps: [add_five, gate, multiply_three]) + + result = workflow.run(10) + expect(result).to be_a(MARS::Halt) + expect(result).to be_global + expect(result.result).to eq("branched:15") + end + + it "propagates global halt through nested sequential workflows" do + inner_gate = MARS::Gate.new( + "InnerGate", + check: ->(_input) { :stop }, + fallbacks: { + stop: Class.new(MARS::Runnable) do + def run(input) + "stopped:#{input}" + end + end.new + }, + halt_scope: :global + ) + + inner = described_class.new("inner", steps: [inner_gate]) + after_inner = add_step_class.new(100) + outer = described_class.new("outer", steps: [inner, after_inner]) + + result = outer.run(1) + # Global halt propagates through both sequential levels + expect(result).to be_a(MARS::Halt) + expect(result.result).to eq("stopped:1") + end + + it "consumes local halt — outer workflow continues" do + inner_gate = MARS::Gate.new( + "InnerGate", + check: ->(_input) { :stop }, + fallbacks: { + stop: Class.new(MARS::Runnable) do + def run(input) + "stopped:#{input}" + end + end.new + } + # default :local scope + ) + + inner = described_class.new("inner", steps: [inner_gate]) + + # Inner halts locally -> returns "stopped:1" as plain value + string_step = Class.new(MARS::Runnable) do + def run(input) + "after:#{input}" + end + end.new + + outer = described_class.new("outer", steps: [inner, string_step]) + + result = outer.run(1) + expect(result).to eq("after:stopped:1") + expect(result).not_to be_a(MARS::Halt) end it "propagates errors from steps" do