├── lib ├── trailblazer-activity.rb └── trailblazer │ ├── activity │ ├── version.rb │ ├── deprecate.rb │ ├── task_wrap │ │ ├── call_task.rb │ │ ├── pipeline.rb │ │ ├── runner.rb │ │ └── extension.rb │ ├── circuit │ │ ├── ruby_with_unfixed_compaction.rb │ │ └── task_adapter.rb │ ├── schema.rb │ ├── introspect │ │ └── render.rb │ ├── structures.rb │ ├── introspect.rb │ ├── task_wrap.rb │ ├── circuit.rb │ ├── testing.rb │ └── adds.rb │ └── activity.rb ├── .gitignore ├── test ├── benchmark │ ├── Gemfile │ ├── commits.rb │ ├── kw.rb │ ├── circuit_api.rb │ └── schema_compiler.rb ├── deprecation_test.rb ├── docs │ └── internals_test.rb ├── structures_test.rb ├── ruby_with_unfixed_compaction_test.rb ├── circuit_test.rb ├── test_helper.rb ├── introspect_test.rb ├── extension_test.rb ├── task_adapter_test.rb ├── activity_test.rb ├── testing_test.rb ├── task_wrap_test.rb └── adds_test.rb ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── Gemfile ├── LICENSE ├── Rakefile ├── trailblazer-activity.gemspec ├── NOTES ├── README.md └── CHANGES.md /lib/trailblazer-activity.rb: -------------------------------------------------------------------------------- 1 | require "trailblazer/activity/version" 2 | require "trailblazer/activity" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /test/benchmark/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "benchmark-ips" 4 | gem "trailblazer-activity", path: "../trailblazer-activity" 5 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/version.rb: -------------------------------------------------------------------------------- 1 | module Trailblazer 2 | module Version 3 | module Activity 4 | VERSION = "0.17.0" 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | # gem "trailblazer-developer", path: "../trailblazer-developer" 5 | # gem "benchmark-ips" 6 | # gem "trailblazer-core-utils", path: "../trailblazer-core-utils" 7 | # gem "trailblazer-core-utils", github: "trailblazer/trailblazer-core-utils" 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-2020 Trailblazer GmbH 2 | 3 | Trailblazer is an Open Source project licensed under the terms of 4 | the LGPLv3 license. Please see 5 | for license text. 6 | 7 | Trailblazer PRO has a commercial-friendly license allowing private forks 8 | and modifications of Trailblazer. Please see http://trailblazer.to/pro for 9 | more detail. 10 | -------------------------------------------------------------------------------- /test/deprecation_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class DeprecationTest < Minitest::Spec 4 | it do 5 | caller_location = caller_locations[0] 6 | 7 | _, err = capture_io do 8 | Trailblazer::Activity::Deprecate.warn caller_location, "so 90s!" 9 | end 10 | 11 | assert_equal err, %{[Trailblazer] #{File.absolute_path(caller_location.absolute_path)}:#{caller_location.lineno} so 90s!\n} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/benchmark/commits.rb: -------------------------------------------------------------------------------- 1 | # be ruby test/benchmark/commits.rb 2 | 3 | commits = { 4 | "ref-1" => "master", 5 | "before optimizations" => "e80dc640b", 6 | "ref-2" => "master" 7 | } 8 | 9 | results = commits.values.collect do |tag| 10 | puts `git checkout #{tag}` 11 | result = `bundle exec ruby test/benchmark/ips.rb` 12 | result = result.split("\n").last.split("-").last 13 | 14 | [tag, result] 15 | end 16 | 17 | pp results 18 | 19 | `git checkout master` 20 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/*_test.rb"] - FileList["test/ruby_with_unfixed_compaction_test.rb"] 8 | end 9 | 10 | task default: %i[test] 11 | 12 | Rake::TestTask.new(:test_gc_bug) do |t| 13 | t.libs << "test" 14 | t.libs << "lib" 15 | t.test_files = FileList["test/ruby_with_unfixed_compaction_test.rb"] 16 | end 17 | -------------------------------------------------------------------------------- /test/benchmark/kw.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | gem "benchmark-ips" 4 | require "benchmark/ips" 5 | 6 | # # Learning 7 | # 8 | # Don't use doublesplat when you don't need it. 9 | # 10 | 11 | def positional(first, kws) 12 | end 13 | 14 | def doublesplat(first, **kws) 15 | end 16 | 17 | # positional: 7971762.3 i/s 18 | # doublesplat: 4536817.7 i/s - 1.76x slower 19 | 20 | first = 1 21 | kws = {} 22 | 23 | Benchmark.ips do |x| 24 | x.report("positional") { positional(first, kws) } 25 | x.report("doublesplat") { doublesplat(first, kws) } 26 | 27 | x.compare! 28 | end 29 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/deprecate.rb: -------------------------------------------------------------------------------- 1 | module Trailblazer 2 | class Activity 3 | module Deprecate 4 | module_function 5 | 6 | def warn(caller_location, message) 7 | location = caller_location ? location_for(caller_location) : nil 8 | warning = [location, message].compact.join(" ") 9 | 10 | Kernel.warn %([Trailblazer] #{warning}\n) 11 | end 12 | 13 | def location_for(caller_location) 14 | line_no = caller_location.lineno 15 | 16 | %(#{caller_location.absolute_path}:#{line_no}) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/docs/internals_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class DocInternalsTest < Minitest::Spec 4 | #:method 5 | def outdated_method 6 | Trailblazer::Activity::Deprecate.warn caller_locations[0], "The `#outdated_method` is deprecated." 7 | 8 | # old code here. 9 | end 10 | #:method end 11 | 12 | #:test 13 | it "gives a deprecation warning" do 14 | _, err = capture_io do 15 | outdated_method() 16 | end 17 | line_no = __LINE__ 18 | 19 | assert_equal err, %([Trailblazer] #{__FILE__}:#{line_no - 2} The `#outdated_method` is deprecated.\n) 20 | end 21 | #:test end 22 | end 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | # Due to https://github.com/actions/runner/issues/849, we have to use quotes for '3.0' 9 | ruby: [2.5, 2.6, 2.7, '3.0', 3.1, 3.2, 3.3, head, jruby, jruby-head] 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: ${{ matrix.ruby }} 16 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 17 | - run: bundle exec rake && bundle exec rake test_gc_bug 18 | -------------------------------------------------------------------------------- /test/structures_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class StructuresTest < Minitest::Spec 4 | describe "End(semantic)" do 5 | let(:evt) { Trailblazer::Activity::End(:meaning) } 6 | 7 | it "#call always returns the End instance itself" do 8 | signal, (_ctx, _flow_options) = evt.([{a: 1}, {}]) 9 | 10 | assert_equal signal, evt 11 | end 12 | 13 | it "responds to #to_h" do 14 | assert_equal evt.to_h, {semantic: :meaning} 15 | end 16 | 17 | it "has strict object identity" do 18 | refute_equal evt, Trailblazer::Activity::End(:meaning) 19 | end 20 | 21 | it "responds to #inspect" do 22 | assert_equal evt.inspect, %(#) 23 | end 24 | 25 | it "allows more variables" do 26 | assert_equal Trailblazer::Activity::End.new(semantic: :success, type: :event).to_h, { semantic: :success, type: :event } 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/task_wrap/call_task.rb: -------------------------------------------------------------------------------- 1 | class Trailblazer::Activity 2 | module TaskWrap 3 | # TaskWrap step that calls the actual wrapped task and passes all `original_args` to it. 4 | # 5 | # It writes to wrap_ctx[:return_signal], wrap_ctx[:return_args] 6 | def self.call_task(wrap_ctx, original_args) 7 | task = wrap_ctx[:task] 8 | 9 | original_arguments, original_circuit_options = original_args 10 | 11 | # Call the actual task we're wrapping here. 12 | # puts "~~~~wrap.call: #{task}" 13 | return_signal, return_args = task.call(original_arguments, **original_circuit_options) 14 | 15 | # DISCUSS: do we want original_args here to be passed on, or the "effective" return_args which are different to original_args now? 16 | wrap_ctx = wrap_ctx.merge( 17 | return_signal: return_signal, 18 | return_args: return_args 19 | ) 20 | 21 | return wrap_ctx, original_args 22 | end 23 | end # Wrap 24 | end 25 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/circuit/ruby_with_unfixed_compaction.rb: -------------------------------------------------------------------------------- 1 | module Trailblazer 2 | class Activity 3 | # TODO: we can remove this once we drop Ruby <= 3.3.6. 4 | class Circuit 5 | # This is a hot fix for Ruby versions that haven't fixed the GC compaction bug: 6 | # https://redmine.ruby-lang.org/issues/20853 7 | # https://bugs.ruby-lang.org/issues/20868 8 | # 9 | # Affected versions might be: 3.1.x, 3.2.?????????, 3.3.0-3.3.6 10 | # You don't need this fix in the following versions: 11 | # 12 | # If you experience this bug: https://github.com/trailblazer/trailblazer-activity/issues/60 13 | # 14 | # NoMethodError: undefined method `[]' for nil 15 | # 16 | # you need to do 17 | # 18 | # Trailblazer::Activity::Circuit.include(RubyWithUnfixedCompaction) 19 | module RubyWithUnfixedCompaction 20 | def initialize(wiring, *args, **options) 21 | wiring.compare_by_identity 22 | 23 | super(wiring, *args, **options) 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /trailblazer-activity.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("lib", __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'trailblazer/activity/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "trailblazer-activity" 7 | spec.version = Trailblazer::Version::Activity::VERSION 8 | spec.authors = ["Nick Sutterer"] 9 | spec.email = ["apotonick@gmail.com"] 10 | 11 | spec.summary = %q{Runtime code for Trailblazer activities.} 12 | spec.homepage = "https://trailblazer.to" 13 | spec.licenses = ["LGPL-3.0"] 14 | 15 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 16 | f.match(%r{^(test)/}) 17 | end 18 | spec.require_paths = ["lib"] 19 | 20 | spec.add_dependency "trailblazer-context", "~> 0.5.0" 21 | spec.add_dependency "trailblazer-option", "~> 0.1.0" 22 | 23 | spec.add_development_dependency "bundler" 24 | spec.add_development_dependency "minitest", "~> 5.0" 25 | spec.add_development_dependency "minitest-line" 26 | spec.add_development_dependency "rake" 27 | spec.add_development_dependency "trailblazer-core-utils", "0.0.4" 28 | 29 | spec.required_ruby_version = '>= 2.1.0' 30 | end 31 | -------------------------------------------------------------------------------- /NOTES: -------------------------------------------------------------------------------- 1 | # 2.2 2 | * The `Polarizations` classes, e.g. in `FastTrack` could be steps in the normalizer? so we wouldn't need `reverse_merge`. 3 | 4 | # NOT lowest level. if you need that, use your own proc. 5 | # TODO: how do we track what goes into the callable? 6 | # adjust what goes into it (e.g. without direction or with kw args)? 7 | # pre contract -> step -> post contract (are these all just steps, in "mini nested pipe"?) 8 | # 9 | # 10 | # aka "Atom". 11 | def self.Task(instance: :context, method: :call, id:nil) 12 | 13 | 14 | # * ingoing contract (could be implemented as a nested pipe with 3 steps. that would allow us 15 | # to compile it to native ruby method calls later) 16 | ->(direction, options, **flow_options) { 17 | instance = flow_options[:context] if instance==:context # TODO; implement different :context (e.g. :my_context). 18 | 19 | 20 | 21 | # * incoming args 22 | # step_args = [args] # TODO: overridable. 23 | step_args = [ options, **options ] 24 | 25 | # ** call the actual thing 26 | res = instance.send(method, *step_args) # what goes in? kws? 27 | 28 | # * interpret result (e.g. true=>Right) (should we keep doing that in the tie? so the op has it easier with success, etc?) 29 | # * outgoing contract 30 | # * outgoing args 31 | 32 | [ *res, flow_options ] 33 | 34 | 35 | # * tracing: incoming, outgoing, direction, etc. - do we want that in tasks, too? 36 | 37 | 38 | } 39 | end 40 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/schema.rb: -------------------------------------------------------------------------------- 1 | module Trailblazer 2 | class Activity 3 | # The idea with {:config} is to have a generic runtime store for feature fields 4 | # like {:wrap_static} but also for flags, e.g. `each: true` from the Each() macro. 5 | class Schema < Struct.new(:circuit, :outputs, :nodes, :config) 6 | # {:nodes} is passed directly from {compile_activity}. We need to store this data here. 7 | 8 | # @!method to_h() 9 | # Returns a hash containing the schema's components. 10 | 11 | class Nodes < Hash 12 | # In Attributes we store data from Intermediate and Implementing compile-time. 13 | # This would be lost otherwise. 14 | Attributes = Struct.new(:id, :task, :data, :outputs) 15 | end 16 | 17 | # Builder for {Schema::Nodes} datastructure. 18 | # 19 | # A {Nodes} instance is a hash of Attributes, keyed by task. It turned out that 20 | # 90% of introspect lookups, we search for attributes for a particular *task*, not ID. 21 | # That's why in 0.16.0 we changed this structure.5 22 | # 23 | # Nodes{# => #} 24 | # 25 | # @private Please use {Introspect.Nodes} for querying nodes. 26 | def self.Nodes(nodes) 27 | Nodes[ 28 | nodes.collect do |attrs| 29 | [ 30 | attrs[1], # task 31 | Nodes::Attributes.new(*attrs).freeze 32 | ] 33 | end 34 | ].freeze 35 | end 36 | end # Schema 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/benchmark/circuit_api.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | gem "benchmark-ips" 4 | require "benchmark/ips" 5 | 6 | # ## Learning 7 | # 8 | # array decompose is only slightly slower. 9 | # splat is super slow 10 | # old "complicated" API is more versatile as it doesn't enforce a set of positional args, 11 | # we only have one and the circuit_options 12 | # 13 | 14 | def old_circuit_api_doublesplat((ctx, flow_options), **_circuit_options) 15 | ctx[:in] = 1 16 | return 1, [ctx, flow_options] 17 | end 18 | 19 | def old_circuit_api_return((ctx, flow_options), _circuit_options) 20 | ctx[:in] = 1 21 | return 1, [ctx, flow_options] 22 | end 23 | 24 | def old_circuit_api((ctx, flow_options), _circuit_options) 25 | ctx[:in] = 1 26 | [1, [ctx, flow_options]] 27 | end 28 | 29 | def new_circuit_api(ctx, flow_options, _circuit_options) 30 | ctx[:in] = 1 31 | [1, [ctx, flow_options]] 32 | end 33 | 34 | def new_circuit_api_shortened(ctx, *args) 35 | ctx[:in] = 1 36 | [1, [ctx, *args]] 37 | end 38 | 39 | ctx = {} 40 | flow_options = {} 41 | circuit_options = {} 42 | 43 | Benchmark.ips do |x| 44 | old_signature = [ctx, flow_options] 45 | 46 | x.report("old_circuit_api_doublesplat") { old_circuit_api_doublesplat(old_signature, circuit_options) } 47 | x.report("old_circuit_api") { old_circuit_api(old_signature, circuit_options) } 48 | x.report("old_circuit_api_return") { old_circuit_api_return(old_signature, circuit_options) } 49 | x.report("new_circuit_api") { new_circuit_api(ctx, flow_options, circuit_options) } 50 | x.report("new_short_api") { new_circuit_api_shortened(ctx, flow_options, circuit_options) } 51 | 52 | x.compare! 53 | end 54 | -------------------------------------------------------------------------------- /lib/trailblazer/activity.rb: -------------------------------------------------------------------------------- 1 | module Trailblazer 2 | # This is DSL-independent code, focusing only on run-time. 3 | # 4 | # Developer's docs: https://trailblazer.to/2.1/docs/internals.html#internals-activity 5 | class Activity 6 | def initialize(schema) 7 | @schema = schema 8 | end 9 | 10 | def call(args, **circuit_options) 11 | @schema[:circuit].( 12 | args, 13 | **circuit_options.merge(activity: self) 14 | ) 15 | end 16 | 17 | def to_h 18 | @schema.to_h 19 | end 20 | 21 | def inspect 22 | %(#) 23 | end 24 | 25 | module Call 26 | # Canonical entry-point to invoke an {Activity} or Strategy such as {Activity::Railway} 27 | # with its taskWrap. 28 | def call(activity, ctx) 29 | TaskWrap.invoke(activity, [ctx, {}]) 30 | end 31 | end 32 | 33 | extend Call # {Activity.call}. 34 | end # Activity 35 | end 36 | 37 | require "trailblazer/activity/structures" 38 | require "trailblazer/activity/schema" 39 | require "trailblazer/activity/circuit" 40 | require "trailblazer/activity/circuit/task_adapter" 41 | require "trailblazer/activity/introspect" 42 | require "trailblazer/activity/task_wrap/pipeline" 43 | require "trailblazer/activity/task_wrap/call_task" 44 | require "trailblazer/activity/task_wrap" 45 | require "trailblazer/activity/task_wrap/runner" 46 | require "trailblazer/activity/task_wrap/extension" 47 | require "trailblazer/activity/adds" 48 | require "trailblazer/activity/deprecate" 49 | require "trailblazer/activity/introspect/render" 50 | require "trailblazer/option" 51 | require "trailblazer/context" 52 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/introspect/render.rb: -------------------------------------------------------------------------------- 1 | module Trailblazer 2 | class Activity 3 | module Introspect 4 | # @private 5 | module Render 6 | module_function 7 | 8 | def call(activity, **options) 9 | nodes = Introspect.Nodes(activity) 10 | circuit_map = activity.to_h[:circuit].to_h[:map] 11 | 12 | content = nodes.collect do |task, node| 13 | outgoings = circuit_map[task] 14 | 15 | conns = outgoings.collect do |signal, target| 16 | " {#{signal}} => #{inspect_with_matcher(target, **options)}" 17 | end 18 | 19 | [ 20 | inspect_with_matcher(node.task, **options), 21 | conns.join("\n") 22 | ] 23 | end 24 | 25 | content = content.join("\n") 26 | 27 | "\n#{content}".gsub(/0x\w+/, "0x") # DISCUSS: use sub logic from core-utils 28 | end 29 | 30 | # If Ruby had pattern matching, this function wouldn't be necessary. 31 | def inspect_with_matcher(task, inspect_task: method(:inspect_task), inspect_end: method(:inspect_end)) 32 | return inspect_task.(task) unless task.is_a?(Trailblazer::Activity::End) 33 | inspect_end.(task) 34 | end 35 | 36 | def inspect_task(task) 37 | task.inspect 38 | end 39 | 40 | def inspect_end(task) 41 | class_name = strip(task.class) 42 | options = task.to_h 43 | 44 | "#<#{class_name}/#{options[:semantic].inspect}>" 45 | end 46 | 47 | def strip(string) 48 | string.to_s.sub("Trailblazer::Activity::", "") 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/structures.rb: -------------------------------------------------------------------------------- 1 | module Trailblazer 2 | class Activity 3 | # Generic run-time structures that are built via the DSL. 4 | 5 | # Any instance of subclass of End will halt the circuit's execution when hit. 6 | # An End event is a simple structure typically found as the last task invoked 7 | # in an activity. The special behavior is that it 8 | # a) maintains a semantic that is used to further connect that very event 9 | # b) its `End#call` method returns the end instance itself as the signal. 10 | class End 11 | def initialize(semantic:, **options) 12 | @options = options.merge(semantic: semantic) 13 | end 14 | 15 | def call(args, **) 16 | return self, args 17 | end 18 | 19 | def to_h 20 | @options 21 | end 22 | 23 | def to_s 24 | %(#<#{self.class.name} #{@options.collect { |k, v| "#{k}=#{v.inspect}" }.join(" ")}>) 25 | end 26 | 27 | alias inspect to_s 28 | end 29 | 30 | class Start < End 31 | def call(args, **) 32 | return Activity::Right, args 33 | end 34 | end 35 | 36 | class Signal; end 37 | 38 | class Right < Signal; end 39 | 40 | class Left < Signal; end 41 | 42 | # signal: actual signal emitted by the task 43 | # color: the mapping, where this signal will travel to. This can be e.g. Left=>:success. The polarization when building the graph. 44 | # "i am traveling towards :success because ::step said so!" 45 | # semantic: the original "semantic" or role of the signal, such as :success. This usually comes from the activity hosting this output. 46 | Output = Struct.new(:signal, :semantic) 47 | 48 | # Builds an {Activity::Output} instance. 49 | def self.Output(signal, semantic) 50 | Output.new(signal, semantic).freeze 51 | end 52 | 53 | # Builds an {Activity::End} instance. 54 | def self.End(semantic) 55 | End.new(semantic: semantic) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/introspect.rb: -------------------------------------------------------------------------------- 1 | module Trailblazer 2 | class Activity 3 | # The Introspect API provides inflections for `Activity` instances. 4 | # 5 | # It abstracts internals about circuits and provides a convenient API to third-parties 6 | # such as tracing, rendering an activity, or finding particular tasks. 7 | module Introspect 8 | # Public entry point for {Activity} instance introspection. 9 | def self.Nodes(activity, task: nil, **options) 10 | schema = activity.to_h 11 | nodes = schema[:nodes] 12 | 13 | return Nodes.find_by_id(nodes, options[:id]) if options.key?(:id) 14 | return nodes.fetch(task) if task 15 | nodes 16 | end 17 | 18 | module Nodes 19 | # @private 20 | # @return Attributes data structure 21 | def self.find_by_id(nodes, id) 22 | tuple = nodes.find { |task, attrs| attrs.id == id } or return 23 | tuple[1] 24 | end 25 | end 26 | 27 | # @private 28 | def self.find_path(activity, segments) 29 | raise ArgumentError.new(%([Trailblazer] Please pass #{activity}.to_h[:activity] into #find_path.)) unless activity.is_a?(Trailblazer::Activity) 30 | 31 | segments = [nil, *segments] 32 | 33 | attributes = nil 34 | last_activity = nil 35 | activity = TaskWrap.container_activity_for(activity) # needed for empty/root path 36 | 37 | segments.each do |segment| 38 | attributes = Introspect.Nodes(activity, id: segment) or return nil 39 | last_activity = activity 40 | activity = attributes.task 41 | end 42 | 43 | return attributes, last_activity 44 | end 45 | 46 | def self.render_task(proc) 47 | if proc.is_a?(Method) 48 | 49 | receiver = proc.receiver 50 | receiver = receiver.is_a?(Class) ? (receiver.name || "#") : (receiver.name || "#") # "#" 51 | 52 | return "#" 53 | elsif proc.is_a?(Symbol) 54 | return proc.to_s 55 | end 56 | 57 | proc.inspect 58 | end 59 | end # Introspect 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/task_wrap/pipeline.rb: -------------------------------------------------------------------------------- 1 | class Trailblazer::Activity 2 | module TaskWrap 3 | # This "circuit" is optimized for 4 | # a) merging speed at run-time, since features like tracing will be applied here. 5 | # b) execution speed. Every task in the real circuit is wrapped with one of us. 6 | # 7 | # It doesn't come with built-in insertion mechanics (except for {Pipeline.prepend}). 8 | # Please add/remove steps using the {Activity::Adds} methods. 9 | class Pipeline 10 | def initialize(sequence) 11 | @sequence = sequence # [[id, task], ..] 12 | end 13 | 14 | # Execute the pipeline and call all its steps, passing around the {wrap_ctx}. 15 | def call(wrap_ctx, original_args) 16 | @sequence.each { |(_id, task)| wrap_ctx, original_args = task.(wrap_ctx, original_args) } 17 | 18 | return wrap_ctx, original_args 19 | end 20 | 21 | # Comply with the Adds interface. 22 | def to_a 23 | @sequence 24 | end 25 | 26 | def self.Row(id, task) 27 | Row[id, task] 28 | end 29 | 30 | class Row < Array 31 | def id 32 | self[0] 33 | end 34 | end 35 | 36 | # Implements adapter for a callable in a Pipeline. 37 | class TaskAdapter < Circuit::TaskAdapter 38 | # Returns a {Pipeline::TaskAdapter} instance that can be used directly in a Pipeline. 39 | # When `call`ed, it returns a Pipeline-interface return set. 40 | # 41 | # @see Circuit::TaskAdapter.for_step 42 | def self.for_step(callable, **) 43 | circuit_step = Circuit.Step(callable, option: false) # Since we don't have {:exec_context} in Pipeline, Option doesn't make much sense. 44 | 45 | TaskAdapter.new(circuit_step) # return a {Pipeline::TaskAdapter} 46 | end 47 | 48 | def call(wrap_ctx, args) 49 | _result, _new_wrap_ctx = @circuit_step.([wrap_ctx, args]) # For funny reasons, the Circuit::Step's call interface is compatible to the Pipeline's. 50 | 51 | # DISCUSS: we're mutating wrap_ctx, that's the whole point of this abstraction (plus kwargs). 52 | 53 | return wrap_ctx, args 54 | end 55 | end # TaskAdapter 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/ruby_with_unfixed_compaction_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.1") && RUBY_ENGINE == "ruby" 4 | # TODO: we can remove this once we drop Ruby <= 3.3.6. 5 | class GCBugTest < Minitest::Spec 6 | it "still finds {.method} tasks after GC compression" do 7 | ruby_version = Gem::Version.new(RUBY_VERSION) 8 | 9 | activity = Fixtures.flat_activity() # {b} and {c} are {.method(:b)} tasks. 10 | 11 | signal, (ctx, _) = activity.([{seq: []}, {}]) 12 | 13 | assert_equal CU.inspect(ctx), %({:seq=>[:b, :c]}) 14 | 15 | if ruby_version >= Gem::Version.new("3.1") && ruby_version < Gem::Version.new("3.2") 16 | require "trailblazer/activity/circuit/ruby_with_unfixed_compaction" 17 | Trailblazer::Activity::Circuit.prepend(Trailblazer::Activity::Circuit::RubyWithUnfixedCompaction) 18 | elsif ruby_version >= Gem::Version.new("3.2.0") && ruby_version <= Gem::Version.new("3.2.6") 19 | require "trailblazer/activity/circuit/ruby_with_unfixed_compaction" 20 | Trailblazer::Activity::Circuit.prepend(Trailblazer::Activity::Circuit::RubyWithUnfixedCompaction) 21 | elsif ruby_version >= Gem::Version.new("3.3.0") #&& ruby_version <= Gem::Version.new("3.3.6") 22 | require "trailblazer/activity/circuit/ruby_with_unfixed_compaction" 23 | Trailblazer::Activity::Circuit.prepend(Trailblazer::Activity::Circuit::RubyWithUnfixedCompaction) 24 | end 25 | 26 | ruby_version_specific_options = 27 | if ruby_version >= Gem::Version.new("3.2") # FIXME: future 28 | {expand_heap: true, toward: :empty} 29 | else 30 | {} 31 | end 32 | 33 | # Provoke the bug: 34 | GC.verify_compaction_references(**ruby_version_specific_options) 35 | 36 | activity = Fixtures.flat_activity() # {b} and {c} are {.method(:b)} tasks. 37 | 38 | # Without the fix, this *might* throw the following exception: 39 | # 40 | # NoMethodError: undefined method `[]' for nil 41 | # /home/nick/projects/trailblazer-activity/lib/trailblazer/activity/circuit.rb:80:in `next_for' 42 | 43 | signal, (ctx, _) = activity.([{seq: []}, {}]) 44 | 45 | assert_equal CU.inspect(ctx), %({:seq=>[:b, :c]}) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/task_wrap.rb: -------------------------------------------------------------------------------- 1 | module Trailblazer 2 | class Activity 3 | # 4 | # Example with tracing: 5 | # 6 | # Call the task_wrap circuit: 7 | # |-- Start 8 | # |-- Trace.capture_args [optional] 9 | # |-- Call (call actual task) id: "task_wrap.call_task" 10 | # |-- Trace.capture_return [optional] 11 | # |-- Wrap::End 12 | module TaskWrap 13 | module_function 14 | 15 | # Compute runtime arguments necessary to execute a taskWrap per task of the activity. 16 | # This method is the top-level entry, called only once for the entire activity graph. 17 | # [:container_activity] the top-most "activity". This only has to look like an Activity 18 | # and exposes a #[] interface so [:wrap_static] can be read and it's compatible to {Trace}. 19 | # It is the virtual activity that "hosts" the actual {activity}. 20 | def invoke(activity, args, wrap_runtime: {}, container_activity: container_activity_for(activity), **circuit_options) 21 | circuit_options = circuit_options.merge( 22 | runner: TaskWrap::Runner, 23 | wrap_runtime: wrap_runtime, 24 | activity: container_activity # for Runner. Ideally we'd have a list of all static_wraps here (even nested). 25 | ) 26 | 27 | # signal, (ctx, flow), circuit_options = 28 | TaskWrap::Runner.(activity, args, **circuit_options) 29 | end 30 | 31 | # {:extension} API 32 | # Extend the static taskWrap from a macro or DSL call. 33 | # Gets executed in {Intermediate.call} which also provides {config}. 34 | def initial_wrap_static 35 | INITIAL_TASK_WRAP 36 | end 37 | 38 | # This is the top-most "activity" that hosts the actual activity being run. 39 | # The data structure is used in {TaskWrap.wrap_static_for}, where we 40 | # access {activity[:wrap_static]} to compile the effective taskWrap. 41 | # 42 | # It's also needed in Trace/Introspect and mimicks the host containing the actual activity. 43 | # 44 | # DISCUSS: we could cache that on Strategy/Operation level. 45 | # merging the **config hash is 1/4 slower than before. 46 | def container_activity_for(activity, wrap_static: initial_wrap_static, id: nil, **config) 47 | { 48 | config: { 49 | wrap_static: {activity => wrap_static}, 50 | **config 51 | }, 52 | nodes: Schema.Nodes([[id, activity]]) 53 | } 54 | end 55 | 56 | ROW_ARGS_FOR_CALL_TASK = ["task_wrap.call_task", TaskWrap.method(:call_task)] # TODO: test me. 57 | INITIAL_TASK_WRAP = Pipeline.new([Pipeline.Row(*ROW_ARGS_FOR_CALL_TASK)].freeze) 58 | end # TaskWrap 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/task_wrap/runner.rb: -------------------------------------------------------------------------------- 1 | class Trailblazer::Activity 2 | module TaskWrap 3 | # The runner is passed into Activity#call( runner: Runner ) and is called for every task in the circuit. 4 | # It runs the TaskWrap per task. 5 | # 6 | # (wrap_ctx, original_args), **wrap_circuit_options 7 | module Runner 8 | # Runner signature: call( task, direction, options, static_wraps ) 9 | # 10 | # @api private 11 | # @interface Runner 12 | def self.call(task, args, **circuit_options) 13 | wrap_ctx = {task: task} 14 | 15 | # this pipeline is "wrapped around" the actual `task`. 16 | task_wrap_pipeline = merge_static_with_runtime(task, **circuit_options) || raise 17 | 18 | # We save all original args passed into this Runner.call, because we want to return them later after this wrap 19 | # is finished. 20 | original_args = [args, circuit_options] 21 | 22 | # call the wrap {Activity} around the task. 23 | wrap_ctx, _ = task_wrap_pipeline.(wrap_ctx, original_args) # we omit circuit_options here on purpose, so the wrapping activity uses the default, plain Runner. 24 | 25 | # don't return the wrap's end signal, but the one from call_task. 26 | # return all original_args for the next "real" task in the circuit (this includes circuit_options). 27 | 28 | return wrap_ctx[:return_signal], wrap_ctx[:return_args] 29 | end 30 | 31 | # Compute the task's wrap by applying alterations both static and from runtime. 32 | # 33 | # NOTE: this is for performance reasons: we could have only one hash containing everything but that'd mean 34 | # unnecessary computations at `call`-time since steps might not even be executed. 35 | # TODO: make this faster. 36 | private_class_method def self.merge_static_with_runtime(task, wrap_runtime:, activity:, **circuit_options) 37 | static_task_wrap = TaskWrap.wrap_static_for(task, activity) # find static wrap for this specific task [, or default wrap activity]. 38 | 39 | # Apply runtime alterations. 40 | # Grab the additional task_wrap extensions for the particular {task} from {:wrap_runtime}. 41 | # DISCUSS: should we allow an array of runtime extensions? And use {Normalizer::TaskWrap.compile_task_wrap_ary_from_extensions} logic? 42 | (dynamic_wrap = wrap_runtime[task]) ? dynamic_wrap.(static_task_wrap) : static_task_wrap 43 | end 44 | end # Runner 45 | 46 | # Retrieve the static wrap config from {activity}. 47 | # @private 48 | def self.wrap_static_for(task, activity) 49 | activity.to_h 50 | .fetch(:config) 51 | .fetch(:wrap_static)[task] # the {wrap_static} for {task}. 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/task_wrap/extension.rb: -------------------------------------------------------------------------------- 1 | module Trailblazer 2 | class Activity 3 | # 4 | # Example with tracing: 5 | # 6 | # Call the task_wrap circuit: 7 | # |-- Start 8 | # |-- Trace.capture_args [optional] 9 | # |-- Call (call actual task) id: "task_wrap.call_task" 10 | # |-- Trace.capture_return [optional] 11 | # |-- Wrap::End 12 | module TaskWrap 13 | # inserts must be 14 | # An {Extension} can be used for {:wrap_runtime}. It expects a collection of 15 | # "friendly interface" arrays. 16 | # 17 | # TaskWrap.Extension([task, id: "my_logger", append: "task_wrap.call_task"], [...]) 18 | # 19 | # If you want a {wrap_static} extension, wrap it using `Extension.WrapStatic.new`. 20 | def self.Extension(*inserts) 21 | Extension.build(*inserts) 22 | end 23 | 24 | # An {Extension} is a collection of ADDS objects to be inserted into a taskWrap. 25 | # It gets called either at 26 | # * compile-time and adds its steps to the wrap_static (see Extension::WrapStatic) 27 | # * run-time in {TaskWrap::Runner} and adds its steps dynamically at runtime to the 28 | # step's taskWrap 29 | # * it's also used in the DSL to create compile-time tW extensions. 30 | class Extension 31 | # Build a taskWrap extension from the "friendly interface" {[task, id:, ...]} 32 | def self.build(*inserts) 33 | # For performance reasons we're computing the ADDS here and not in {#call}. 34 | extension_rows = Activity::Adds::FriendlyInterface.adds_for(inserts) 35 | 36 | new(*extension_rows) 37 | end 38 | 39 | def initialize(*extension_rows) 40 | @extension_rows = extension_rows # those rows are simple ADDS instructions. 41 | end 42 | 43 | # Merges {extension_rows} into the {Pipeline} instance. 44 | # This is usually used in step extensions or at runtime for {wrap_runtime}. 45 | def call(task_wrap_pipeline, **) 46 | Adds.apply_adds(task_wrap_pipeline, @extension_rows) 47 | end 48 | 49 | # Create extensions from the friendly interface that can alter the wrap_static 50 | # of a step in an activity. The returned extensionn can be passed directly via {:extensions} 51 | # to the compiler, or when using the `#step` DSL. 52 | # TODO: remove. 53 | def self.WrapStatic(*inserts) 54 | Activity::Deprecate.warn caller_locations[0], "Using `TaskWrap::Extension.WrapStatic()` is deprecated. Please use `TaskWrap.Extension()`." 55 | 56 | # FIXME: change or deprecate. 57 | TaskWrap.Extension(*inserts) 58 | end 59 | end # Extension 60 | end # TaskWrap 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/circuit_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | # DISCUSS: 2BRM? 4 | class CircuitTest < Minitest::Spec 5 | Start = ->((options, *args), *_circuit_options) { options[:start] = 1; ["to a", [options, *args]] } 6 | A = ->((options, *args), *_circuit_options) { options[:a] = 2; ["from a", [options, *args]] } 7 | B = ->((options, *args), *_circuit_options) { options[:b] = 3; ["from b", [options, *args]] } 8 | End = ->((options, *args), *_circuit_options) { options[:_end] = 4; ["the end", [options, *args]] } 9 | C = ->((options, *args), *_circuit_options) { options[:c] = 6; ["from c", [options, *args]] } 10 | 11 | it do 12 | map = { 13 | Start => {"to a" => A, "to b" => B}, 14 | A => {"from a" => B}, 15 | B => {"from b" => End} 16 | } 17 | 18 | circuit = Trailblazer::Activity::Circuit.new(map, [End], start_task: map.keys.first) 19 | 20 | ctx = {} 21 | 22 | last_signal, (ctx, i, j, *bla) = circuit.([ctx, 1, 2], task: Start) 23 | 24 | assert_equal CU.inspect(ctx), %{{:start=>1, :a=>2, :b=>3, :_end=>4}} 25 | assert_equal last_signal, "the end" 26 | assert_equal i, 1 27 | assert_equal j, 2 28 | assert_equal bla, [] 29 | 30 | # --- 31 | 32 | ctx = {} 33 | flow_options = {stack: []} 34 | 35 | last_signal, (ctx, i, j, *bla) = circuit.([ctx, flow_options, 2], task: Start, runner: MyRunner) 36 | 37 | assert_equal flow_options, { stack: [Start, A, B, End] } 38 | end 39 | 40 | MyRunner = ->(task, args, **circuit_options) do 41 | MyTrace.(task, args, **circuit_options) 42 | 43 | task.(args, **circuit_options) 44 | end 45 | 46 | MyTrace = ->(task, (_options, flow_options), **_circuit_options) { flow_options[:stack] << task } 47 | 48 | let(:nestable) do 49 | nest_map = { 50 | Start => {"to a" => C}, 51 | C => {"from c" => End} 52 | } 53 | 54 | nest = Trailblazer::Activity::Circuit.new(nest_map, [End], start_task: nest_map.keys.first) 55 | 56 | # FIXME: FROM Activity#call 57 | nest_call = ->((options, flow_options, *args), **circuit_options) { 58 | nest.([options, flow_options, *args], **circuit_options.merge(task: Start)) 59 | } 60 | 61 | nest_call 62 | end 63 | 64 | let(:outer) do 65 | outer_map = { 66 | Start => { "to a" => A, "to b" => B }, 67 | A => { "from a" => nestable }, 68 | nestable => { "the end" => B }, 69 | B => { "from b" => End} 70 | } 71 | 72 | Trailblazer::Activity::Circuit.new(outer_map, [End], start_task: outer_map.keys.first) 73 | end 74 | 75 | it "allows nesting circuits by using a nesting callable" do 76 | ctx = {} 77 | 78 | last_signal, (ctx, i, j, *bla) = outer.([ctx, {}, 2], task: Start) 79 | 80 | assert_equal CU.inspect(ctx), %{{:start=>1, :a=>2, :c=>6, :_end=>4, :b=>3}} 81 | assert_equal last_signal, "the end" 82 | assert_equal i,({}) 83 | assert_equal j, 2 84 | assert_equal bla, [] 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "pp" 2 | require "trailblazer-activity" 3 | require "minitest/autorun" 4 | 5 | Minitest::Spec.class_eval do 6 | def assert_equal(asserted, expected, *args) 7 | super(expected, asserted, *args) 8 | end 9 | 10 | require "trailblazer/activity/testing" 11 | include Trailblazer::Activity::Testing::Assertions 12 | 13 | module Minitest::Spec::Implementing 14 | extend Trailblazer::Activity::Testing.def_tasks(:a, :b, :c, :d, :f, :g) 15 | 16 | Start = Trailblazer::Activity::Start.new(semantic: :default) 17 | Failure = Trailblazer::Activity::End(:failure) 18 | Success = Trailblazer::Activity::End(:success) 19 | end 20 | end 21 | 22 | T = Trailblazer::Activity::Testing 23 | 24 | module Fixtures 25 | # TODO: test this. 26 | def self.default_tasks(_old_ruby_kws = {}, **tasks) 27 | tasks = tasks.merge(_old_ruby_kws) 28 | 29 | { 30 | "Start.default" => Trailblazer::Activity::Start.new(semantic: :default), 31 | # tasks 32 | "b" => Minitest::Spec::Implementing.method(:b), 33 | "c" => Minitest::Spec::Implementing.method(:c), 34 | "End.failure" => Trailblazer::Activity::End(:failure), 35 | "End.success" => Trailblazer::Activity::End(:success), 36 | }.merge(tasks) 37 | end 38 | 39 | # TODO: test this. 40 | def self.default_wiring(tasks, _old_ruby_kws = {}, **connections) 41 | connections = connections.merge(_old_ruby_kws) 42 | 43 | start, b, c, failure, success = tasks.values 44 | 45 | { 46 | start => {Trailblazer::Activity::Right => b}, 47 | b => {Trailblazer::Activity::Right => c, Trailblazer::Activity::Left => failure}, 48 | c => {Trailblazer::Activity::Right => success}, 49 | }.merge(connections) 50 | end 51 | 52 | def self.flat_activity(wiring: nil, tasks: self.default_tasks, config: {}) 53 | start, b, c, failure, success = tasks.values 54 | 55 | wiring ||= Fixtures.default_wiring(tasks) 56 | 57 | # standard outputs, for introspection interface. 58 | right_output = Trailblazer::Activity::Output(Trailblazer::Activity::Right, :success) 59 | left_output = Trailblazer::Activity::Output(Trailblazer::Activity::Left, :failure) 60 | 61 | # FIXME: allow overriding this. 62 | nodes_attributes = [ 63 | # id, task, data, [outputs] 64 | ["Start.default", start, {}, [right_output]], 65 | ["b", b, {}, [right_output, left_output]], 66 | ["c", c, {}, [right_output]], 67 | ["End.failure", failure, {stop_event: true}, []], 68 | ["End.success", success, {stop_event: true}, []], 69 | ] 70 | 71 | circuit = Trailblazer::Activity::Circuit.new( 72 | wiring, 73 | [failure, success], # termini 74 | start_task: start 75 | ) 76 | 77 | activity_outputs = [ 78 | Trailblazer::Activity::Output(failure, :failure), 79 | Trailblazer::Activity::Output(success, :success) 80 | ] 81 | 82 | 83 | # add :wrap_static here. 84 | # config = { 85 | # } 86 | 87 | schema = Trailblazer::Activity::Schema.new( 88 | circuit, 89 | activity_outputs, 90 | Trailblazer::Activity::Schema::Nodes(nodes_attributes), 91 | config 92 | ) 93 | 94 | Trailblazer::Activity.new(schema) 95 | end 96 | end 97 | 98 | 99 | Minitest::Spec.class_eval do 100 | require "trailblazer/core" 101 | CU = Trailblazer::Core::Utils 102 | end 103 | -------------------------------------------------------------------------------- /test/introspect_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class IntrospectionTest < Minitest::Spec 4 | describe "Introspect.find_path" do 5 | it "#find_path" do 6 | flat_activity = Fixtures.flat_activity # [b, c] 7 | 8 | middle_tasks = Fixtures.default_tasks("b" => flat_activity) 9 | middle_activity = Fixtures.flat_activity(tasks: middle_tasks) # [b, [b, c]] 10 | 11 | tasks = Fixtures.default_tasks("b" => middle_activity) 12 | activity = Fixtures.flat_activity(tasks: tasks) # [b, [b, [b,c]]] 13 | 14 | 15 | #@ find top-activity which returns a special Node. 16 | node, host_activity, graph = Trailblazer::Activity::Introspect.find_path(activity, []) 17 | assert_equal node.class, Trailblazer::Activity::Schema::Nodes::Attributes 18 | assert_equal node[:task], activity 19 | assert_equal host_activity, Trailblazer::Activity::TaskWrap.container_activity_for(activity) 20 | 21 | #@ one element path 22 | node, host_activity, graph = Trailblazer::Activity::Introspect.find_path(activity, ["b"]) 23 | assert_equal node.class, Trailblazer::Activity::Schema::Nodes::Attributes 24 | assert_equal node[:task], middle_activity 25 | assert_equal host_activity, activity 26 | 27 | #@ nested element 28 | node, host_activity, _graph = Trailblazer::Activity::Introspect.find_path(activity, ["b", "b", "c"]) 29 | assert_equal node.class, Trailblazer::Activity::Schema::Nodes::Attributes 30 | assert_equal node[:task], Implementing.method(:c) 31 | assert_equal host_activity, flat_activity 32 | 33 | #@ non-existent element 34 | assert_nil Trailblazer::Activity::Introspect.find_path(activity, [:c]) 35 | assert_nil Trailblazer::Activity::Introspect.find_path(activity, ["flat_activity", :c]) 36 | end 37 | end 38 | 39 | describe "Introspect.Nodes()" do 40 | let(:task_map) { Trailblazer::Activity::Introspect.Nodes(Fixtures.flat_activity) } # [B, C] 41 | 42 | it "returns Nodes that looks like a Hash" do 43 | assert_equal task_map.class, Trailblazer::Activity::Schema::Nodes 44 | end 45 | 46 | it "exposes #[] to find by task" do 47 | attributes = task_map[Implementing.method(:b)] 48 | assert_equal attributes.id, "b" 49 | assert_equal attributes.task, Implementing.method(:b) 50 | 51 | #@ non-existent 52 | assert_nil task_map[nil] 53 | end 54 | 55 | it "exposes #fetch to find by task" do 56 | assert_equal task_map.fetch(Implementing.method(:b)).id, "b" 57 | 58 | #@ non-existent 59 | assert_raises KeyError do task_map.fetch(nil) end 60 | end 61 | 62 | it "accepts {:id} option" do 63 | attrs = Trailblazer::Activity::Introspect.Nodes(Fixtures.flat_activity, id: "b") 64 | 65 | assert_equal attrs.id, "b" 66 | assert_equal attrs.task, Implementing.method(:b) 67 | end 68 | 69 | it "accepts {id: nil}" do 70 | flat_activity = Fixtures.flat_activity 71 | 72 | container_activity = Trailblazer::Activity::TaskWrap.container_activity_for(flat_activity) 73 | 74 | attrs = Trailblazer::Activity::Introspect.Nodes(container_activity, id: nil) 75 | 76 | 77 | assert_equal attrs.id, nil 78 | assert_equal attrs.task, flat_activity 79 | end 80 | 81 | it "accepts {:task} option" do 82 | attrs = Trailblazer::Activity::Introspect.Nodes(Fixtures.flat_activity, task: Implementing.method(:b)) 83 | 84 | assert_equal attrs.id, "b" 85 | assert_equal attrs.task, Implementing.method(:b) 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/extension_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | # High-level interface to ADDS. 4 | class ExtensionTest < Minitest::Spec 5 | it "provides several insertion strategies" do 6 | # create new task_wrap with empty original array. 7 | ext = Trailblazer::Activity::TaskWrap.Extension( 8 | [Object, id: "task_wrap.call_task", append: nil] 9 | ) 10 | 11 | task_wrap = ext.([]) 12 | 13 | assert_equal task_wrap.inspect, %([["task_wrap.call_task", Object]]) 14 | 15 | ext = Trailblazer::Activity::TaskWrap.Extension( 16 | [Object, id: "task_wrap.call_task", replace: "task_wrap.call_task"] 17 | ) 18 | 19 | task_wrap = ext.(task_wrap) 20 | 21 | assert_equal task_wrap.inspect, %([["task_wrap.call_task", Object]]) 22 | 23 | ext = Trailblazer::Activity::TaskWrap.Extension( 24 | [Module, id: "my.before", prepend: "task_wrap.call_task"], 25 | [Class, id: "my.after", append: "task_wrap.call_task"], 26 | ) 27 | 28 | task_wrap = ext.(task_wrap) 29 | 30 | assert_equal task_wrap.inspect, %([["my.before", Module], ["task_wrap.call_task", Object], ["my.after", Class]]) 31 | 32 | ext = Trailblazer::Activity::TaskWrap.Extension( 33 | [String, id: "my.prepend", prepend: nil], 34 | [Float, id: "my.append", append: nil], 35 | ) 36 | 37 | task_wrap = ext.(task_wrap) 38 | 39 | assert_equal task_wrap.inspect, %([["my.prepend", String], ["my.before", Module], ["task_wrap.call_task", Object], ["my.after", Class], ["my.append", Float]]) 40 | end 41 | 42 | it "allows using step IDs from earlier inserted steps" do 43 | ext = Trailblazer::Activity::TaskWrap.Extension( 44 | [Object, id: "task_wrap.call_task", append: nil], 45 | [Module, id: "my.object", append: "task_wrap.call_task"], 46 | [Class, id: "my.class", prepend: "my.object"], # my.object is the step from above. 47 | ) 48 | 49 | task_wrap = ext.([]) 50 | 51 | assert_equal task_wrap.inspect, %([["task_wrap.call_task", Object], ["my.class", Class], ["my.object", Module]]) 52 | end 53 | 54 | describe "Extension" do 55 | def add_1(wrap_ctx, original_args) 56 | ctx, = original_args[0] 57 | ctx[:seq] << 1 58 | return wrap_ctx, original_args # yay to mutable state. not. 59 | end 60 | 61 | it "deprecates {TaskWrap.WrapStatic}" do 62 | adds = [ 63 | [method(:add_1), id: "user.add_1", prepend: "task_wrap.call_task"], 64 | # [method(:add_2), id: "user.add_2", append: "task_wrap.call_task"], 65 | ] 66 | 67 | ext = nil 68 | _, warning = capture_io do 69 | ext = Trailblazer::Activity::TaskWrap::Extension.WrapStatic(*adds) 70 | end 71 | line_number_for_binary = __LINE__ - 2 72 | 73 | # lines = warning.split("\n") 74 | # lines[0] = lines[0][0..-5]+"." if lines[0] =~ /\d-\d+-\d/ 75 | # warning = lines.join("\n") 76 | 77 | assert_equal warning, %{[Trailblazer] #{File.realpath(__FILE__)}:#{line_number_for_binary} Using `TaskWrap::Extension.WrapStatic()` is deprecated. Please use `TaskWrap.Extension()`.\n} 78 | assert_equal ext.class, Trailblazer::Activity::TaskWrap::Extension 79 | # DISCUSS: should we test if the extension is correct? 80 | end 81 | 82 | it "{Extension#call} accepts **options" do 83 | adds = [ 84 | [Object, id: "user.add_1", prepend: nil], 85 | ] 86 | 87 | ext = Trailblazer::Activity::TaskWrap::Extension(*adds) 88 | 89 | task_wrap = ext.([], some: :options) 90 | 91 | assert_equal task_wrap.inspect, %([["user.add_1", Object]]) 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/circuit.rb: -------------------------------------------------------------------------------- 1 | module Trailblazer 2 | class Activity 3 | # Running a Circuit instance will run all tasks sequentially depending on the former's result. 4 | # Each task is called and retrieves the former task's return values. 5 | # 6 | # Note: Please use #Activity as a public circuit builder. 7 | # 8 | # @param map [Hash] Defines the wiring. 9 | # @param termini [Array] Tasks that stop execution of the circuit. 10 | # 11 | # result = circuit.(start_at, *args) 12 | # 13 | # @see Activity 14 | # @api semi-private 15 | # 16 | # This is the "pipeline operator"'s implementation. 17 | class Circuit 18 | def initialize(map, termini, start_task:, name: nil) 19 | @map = map 20 | @termini = termini 21 | @name = name 22 | @start_task = start_task 23 | end 24 | 25 | # @param args [Array] all arguments to be passed to the task's `call` 26 | # @param task [callable] task to call 27 | Runner = ->(task, args, **circuit_options) { task.(args, **circuit_options) } 28 | 29 | # Runs the circuit until we hit a stop event. 30 | # 31 | # This method throws exceptions when the returned value of a task doesn't match 32 | # any wiring. 33 | # 34 | # @param task An event or task of this circuit from where to start 35 | # @param options anything you want to pass to the first task 36 | # @param flow_options Library-specific flow control data 37 | # @return [last_signal, options, flow_options, *args] 38 | # 39 | # NOTE: returned circuit_options are discarded when calling the runner. 40 | def call(args, start_task: @start_task, runner: Runner, **circuit_options) 41 | circuit_options = circuit_options.merge(runner: runner) # TODO: set the :runner option via arguments_for_call to save the merge? 42 | task = start_task 43 | 44 | loop do 45 | last_signal, args, _discarded_circuit_options = runner.( 46 | task, 47 | args, 48 | **circuit_options 49 | ) 50 | 51 | # Stop execution of the circuit when we hit a terminus. 52 | return [last_signal, args] if @termini.include?(task) 53 | 54 | if (next_task = next_for(task, last_signal)) 55 | task = next_task 56 | else 57 | raise IllegalSignalError.new( 58 | task, 59 | signal: last_signal, 60 | outputs: @map[task], 61 | exec_context: circuit_options[:exec_context] # passed at run-time from DSL 62 | ) 63 | end 64 | end 65 | end 66 | 67 | # Returns the circuit's components. 68 | def to_h 69 | { 70 | map: @map, 71 | end_events: @termini, # TODO: deprecate {:end_events} and name it {:termini}. 72 | start_task: @start_task 73 | } 74 | end 75 | 76 | private 77 | 78 | def next_for(last_task, signal) 79 | outputs = @map[last_task] 80 | outputs[signal] 81 | end 82 | 83 | # Common reasons to raise IllegalSignalError are when returning signals from 84 | # * macros which are not registered 85 | # * subprocesses where parent process have not registered that signal 86 | # * ciruit interface steps, for example: `step task: method(:validate)` 87 | class IllegalSignalError < RuntimeError 88 | attr_reader :task, :signal 89 | 90 | def initialize(task, signal:, outputs:, exec_context:) 91 | @task = task 92 | @signal = signal 93 | 94 | message = "#{exec_context.class}:\n" \ 95 | "\e[31mUnrecognized signal `#{signal.inspect}` returned from #{task.inspect}. Registered signals are:\e[0m\n" \ 96 | "\e[32m#{outputs.keys.map(&:inspect).join("\n")}\e[0m" 97 | 98 | super(message) 99 | end 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Activity 2 | 3 | Implements Intermediate, Implementation and compiler 4 | 5 | The `activity` gem implements the runtime logic to invoke a new abstraction called "activities". Ideally, activities are defined using the [`dsl-linear` DSL gem](https://github.com/trailblazer/trailblazer-activity-dsl-linear). 6 | 7 | A process is a set of arbitrary pieces of logic you define, chained together and put into a meaningful context by an activity. Activity lets you focus on the implementation of steps while Trailblazer takes care of the control flow. 8 | 9 | Please find the [full documentation on the Trailblazer website](https://trailblazer.to/2.1/docs/activity.html). 10 | 11 | ## Example 12 | 13 | In conjunction with [`dsl-linear`](https://github.com/trailblazer/trailblazer-activity-dsl-linear), the `activity` gem provides three default patterns to model processes: `Path`, `Railway` and `FastTrack`. Here's an example of what a railway activity could look like, along with some more complex connections (you can read more about Railway strategy in the [docs](https://trailblazer.to/2.1/docs/activity.html#activity-strategy-railway)). 14 | 15 | ```ruby 16 | require "trailblazer-activity" 17 | require "trailblazer-activity-dsl-linear" 18 | 19 | class Memo::Update < Trailblazer::Activity::Railway 20 | # here goes your business logic 21 | # 22 | def find_model(ctx, id:, **) 23 | ctx[:model] = Memo.find_by(id: id) 24 | end 25 | 26 | def validate(ctx, params:, **) 27 | return true if params[:body].is_a?(String) && params[:body].size > 10 28 | ctx[:errors] = "body not long enough" 29 | false 30 | end 31 | 32 | def save(ctx, model:, params:, **) 33 | model.update_attributes(params) 34 | end 35 | 36 | def log_error(ctx, params:, **) 37 | ctx[:log] = "Some idiot wrote #{params.inspect}" 38 | end 39 | 40 | # here comes the DSL describing the layout of the activity 41 | # 42 | step :find_model 43 | step :validate, Output(:failure) => End(:validation_error) 44 | step :save 45 | fail :log_error 46 | end 47 | ``` 48 | 49 | Visually, this would translate to the following circuit. 50 | 51 | 52 | 53 | You can run the activity by invoking its `call` method. 54 | 55 | ```ruby 56 | ctx = { id: 1, params: { body: "Awesome!" } } 57 | 58 | signal, (ctx, *) = Update.( [ctx, {}] ) 59 | 60 | pp ctx #=> 61 | {:id=>1, 62 | :params=>{:body=>"Awesome!"}, 63 | :model=>#, 64 | :errors=>"body not long enough"} 65 | 66 | pp signal #=> # 67 | ``` 68 | 69 | With Activity, modeling business processes turns out to be ridiculously simple: You define what should happen and when, and Trailblazer makes sure _that_ it happens. 70 | 71 | ## Features 72 | 73 | * Activities can model any process with arbitrary flow and connections. 74 | * Nesting and compositions are allowed and encouraged (via Trailblazer's [`dsl-linear`](https://github.com/trailblazer/trailblazer-activity-dsl-linear) gem). 75 | * Different step interfaces, manual processing of DSL options, etc is all possible. 76 | * Steps can be any kind of callable objects. 77 | * Tracing! (via Trailblazer's [`developer`](https://github.com/trailblazer/trailblazer-developer) gem) 78 | 79 | ## Operation 80 | 81 | Trailblazer's [`Operation`](https://trailblazer.to/2.1/docs/operation.html#operation-overview) internally uses an activity to model the processes. 82 | 83 | ## Workflow 84 | Activities can be formed into bigger compounds and using workflow, you can build long-running processes such as a moderated blog post or a parcel delivery. Also, you don't have to use the DSL but can use the [`editor`](https://trailblazer.to/2.1/docs/pro.html#pro-editor)instead(cool for more complex, long-running flows). Here comes a sample screenshot. 85 | 86 | 87 | 88 | ## License 89 | 90 | © Copyright 2018, Trailblazer GmbH 91 | 92 | Licensed under the LGPLv3 license. We also offer a commercial-friendly [license](https://trailblazer.to/2.1/docs/pro.html#pro-license). 93 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/testing.rb: -------------------------------------------------------------------------------- 1 | # DISCUSS: move to trailblazer-activity-test ? 2 | 3 | # Helpers to quickly create steps and tasks. 4 | module Trailblazer 5 | module Activity::Testing 6 | # Creates a module with one step method for each name. 7 | # 8 | # @example 9 | # extend T.def_steps(:create, :save) 10 | def self.def_steps(*names) 11 | Module.new do 12 | module_function 13 | 14 | names.each do |name| 15 | define_method(name) do |ctx, **| 16 | ctx[:seq] << name 17 | ctx.key?(name) ? ctx[name] : true 18 | end 19 | end 20 | end 21 | end 22 | 23 | # Creates a method instance with a task interface. 24 | # 25 | # @example 26 | # task task: T.def_task(:create) 27 | def self.def_task(name) 28 | def_tasks(name).method(name) 29 | end 30 | 31 | def self.def_tasks(*names) 32 | Module.new do 33 | module_function 34 | 35 | names.each do |name| 36 | define_method(name) do |(ctx, flow_options), **| 37 | ctx[:seq] << name 38 | signal = ctx.key?(name) ? ctx[name] : Activity::Right 39 | 40 | return signal, [ctx, flow_options] 41 | end 42 | end 43 | end 44 | end 45 | 46 | module Assertions 47 | # `:seq` is always passed into ctx. 48 | # @param :seq String What the {:seq} variable in the result ctx looks like. (expected seq) 49 | # @param :expected_ctx_variables Variables that are added during the call by the asserted activity. 50 | def assert_call(activity, terminus: :success, seq: "[]", expected_ctx_variables: {}, **ctx_variables) 51 | # Call without taskWrap! 52 | signal, (ctx, _) = activity.([{seq: [], **ctx_variables}, _flow_options = {}]) # simply call the activity with the input you want to assert. 53 | 54 | assert_call_for(signal, ctx, terminus: terminus, seq: seq, **expected_ctx_variables, **ctx_variables) 55 | end 56 | 57 | # Use {TaskWrap.invoke} to call the activity. 58 | def assert_invoke(activity, terminus: :success, seq: "[]", circuit_options: {}, flow_options: {}, expected_ctx_variables: {}, **ctx_variables) 59 | signal, (ctx, returned_flow_options) = Activity::TaskWrap.invoke( 60 | activity, 61 | [ 62 | {seq: [], **ctx_variables}, 63 | flow_options, 64 | ], 65 | **circuit_options 66 | ) 67 | 68 | assert_call_for(signal, ctx, terminus: terminus, seq: seq, **ctx_variables, **expected_ctx_variables) # DISCUSS: ordering of variables? 69 | 70 | return signal, [ctx, returned_flow_options] 71 | end 72 | 73 | def assert_call_for(signal, ctx, terminus: :success, seq: "[]", **ctx_variables) 74 | assert_equal signal.to_h[:semantic], terminus, "assert_call expected #{terminus} terminus, not #{signal}. Use assert_call(activity, terminus: #{signal.to_h[:semantic].inspect})" 75 | 76 | assert_equal ctx.inspect, {seq: "%%%"}.merge(ctx_variables).inspect.sub('"%%%"', seq) 77 | 78 | return ctx 79 | end 80 | 81 | # Tests {:circuit} and {:outputs} fields so far. 82 | def assert_process_for(process, *args) 83 | semantics, circuit = args[0..-2], args[-1] 84 | 85 | assert_equal semantics.sort, process.to_h[:outputs].collect { |output| output[:semantic] }.sort 86 | 87 | assert_circuit(process, circuit) 88 | 89 | process 90 | end 91 | 92 | alias_method :assert_process, :assert_process_for 93 | 94 | def assert_circuit(schema, circuit) 95 | cct = Cct(schema) 96 | 97 | cct = cct.gsub("#(ctx, model:, **) do 19 | return if model.nil? 20 | 21 | ctx[:type] = model.class 22 | end 23 | 24 | flow_options = {} 25 | circuit_options = {} 26 | 27 | ctx = {model: nil} 28 | 29 | # execute step in a circuit-interface environment 30 | circuit_step = Trailblazer::Activity::Circuit::Step(step) # circuit step receives circuit-interface but returns only value 31 | return_value, ctx = circuit_step.([ctx, flow_options], **circuit_options) 32 | 33 | assert_nil return_value 34 | assert_equal CU.inspect(ctx), %{{:model=>nil}} 35 | 36 | 37 | #@ execute step-option in a circuit-interface environment 38 | circuit_step = Trailblazer::Activity::Circuit.Step(step, option: true) # wrap step with Trailblazer::Option 39 | return_value, ctx = circuit_step.([ctx, flow_options], **circuit_options, exec_context: self) 40 | assert_nil return_value 41 | assert_equal CU.inspect(ctx), %{{:model=>nil}} 42 | 43 | circuit_step = Trailblazer::Activity::Circuit::Step(:process_type, option: true) # wrap step with Trailblazer::Option 44 | return_value, ctx = circuit_step.([ctx, flow_options], **circuit_options, exec_context: Operation.new) 45 | assert_nil return_value 46 | assert_equal CU.inspect(ctx), %{{:model=>nil}} 47 | 48 | #@ circuit-interface Option-compatible Step that does a Binary signal decision 49 | step = :process_type 50 | circuit_task = Trailblazer::Activity::Circuit::TaskAdapter.for_step(step, option: true) 51 | 52 | ctx = {model: nil} 53 | signal, (ctx, flow_options) = circuit_task.([ctx, flow_options], **circuit_options, exec_context: Operation.new) 54 | 55 | assert_equal signal, Trailblazer::Activity::Left 56 | assert_equal CU.inspect(ctx), %{{:model=>nil}} 57 | assert_equal flow_options.inspect, %{{}} 58 | 59 | ctx = {model: Object} 60 | signal, (ctx, flow_options) = circuit_task.([ctx, flow_options], **circuit_options, exec_context: Operation.new) 61 | 62 | assert_equal signal, Trailblazer::Activity::Right 63 | assert_equal CU.inspect(ctx), %{{:model=>Object, :type=>Class}} 64 | assert_equal flow_options.inspect, %{{}} 65 | 66 | ctx = {model: nil} 67 | signal, (ctx, flow_options) = circuit_task.([ctx, flow_options], **circuit_options, exec_context: Operation.new) 68 | 69 | assert_equal signal, Trailblazer::Activity::Left 70 | assert_equal CU.inspect(ctx), %{{:model=>nil}} 71 | assert_equal flow_options.inspect, %{{}} 72 | 73 | #@ pipeline-interface 74 | 75 | # we don't have {:exec_context} in a Pipeline! 76 | step = Operation.new.method(:process_type) 77 | args = [1,2] 78 | ctx = {model: Object} 79 | 80 | pipeline_task = Trailblazer::Activity::TaskWrap::Pipeline::TaskAdapter.for_step(step) # Task receives circuit-interface but it's compatible with Pipeline interface 81 | wrap_ctx, args = pipeline_task.(ctx, args) # that's how pipeline tasks are called in {TaskWrap::Pipeline}. 82 | 83 | assert_equal CU.inspect(wrap_ctx), %{{:model=>Object, :type=>Class}} 84 | assert_equal args, [1,2] 85 | end 86 | 87 | # TODO: properly test {TaskAdapter#inspect}. 88 | 89 | it "AssignVariable" do 90 | skip "# DISCUSS: Do we need AssignVariable?" 91 | 92 | decider = ->(ctx, mode:, **) { mode } 93 | 94 | circuit_task = Trailblazer::Activity::Circuit::TaskAdapter.Binary( 95 | decider, 96 | adapter_class: Trailblazer::Activity::Circuit::TaskAdapter::Step::AssignVariable, 97 | variable_name: :nested_activity 98 | ) 99 | 100 | ctx = {mode: Object} 101 | signal, (ctx, flow_options) = circuit_task.([ctx, {}], **{exec_context: nil}) 102 | 103 | assert_equal signal, Trailblazer::Activity::Right 104 | assert_equal CU.inspect(ctx), %{{:mode=>Object, :nested_activity=>Object}} 105 | 106 | #@ returning false 107 | ctx = {mode: false} 108 | signal, (ctx, flow_options) = circuit_task.([ctx, {}], **{exec_context: nil}) 109 | 110 | assert_equal signal, Trailblazer::Activity::Left 111 | assert_equal CU.inspect(ctx), %{{:mode=>false, :nested_activity=>false}} 112 | end 113 | 114 | end 115 | -------------------------------------------------------------------------------- /test/benchmark/schema_compiler.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | gem "benchmark-ips" 4 | require "benchmark/ips" 5 | Activity = Trailblazer::Activity 6 | Schema = Activity::Schema 7 | 8 | intermediate = Schema::Intermediate.new( 9 | { 10 | Schema::Intermediate::TaskRef(:a) => [Schema::Intermediate::Out(:success, :b)], 11 | Schema::Intermediate::TaskRef(:b) => [Schema::Intermediate::Out(:success, :c)], 12 | Schema::Intermediate::TaskRef(:c) => [Schema::Intermediate::Out(:success, :d)], 13 | Schema::Intermediate::TaskRef(:d) => [Schema::Intermediate::Out(:success, :e)], 14 | Schema::Intermediate::TaskRef(:f) => [Schema::Intermediate::Out(:success, :g)], 15 | Schema::Intermediate::TaskRef(:h) => [Schema::Intermediate::Out(:success, :i)], 16 | Schema::Intermediate::TaskRef(:j) => [Schema::Intermediate::Out(:success, :k)], 17 | Schema::Intermediate::TaskRef(:l) => [Schema::Intermediate::Out(:success, :m)], 18 | Schema::Intermediate::TaskRef(:n) => [Schema::Intermediate::Out(:success, :o)], 19 | Schema::Intermediate::TaskRef(:c) => [Schema::Intermediate::Out(:success, :d)], 20 | Schema::Intermediate::TaskRef(:p) => [Schema::Intermediate::Out(:success, :q)], 21 | Schema::Intermediate::TaskRef(:q) => [Schema::Intermediate::Out(:success, nil)] 22 | }, 23 | [:q], # terminus 24 | [:a] # start 25 | ) 26 | 27 | # DISCUSS: in Ruby 3, procs created from the same block are identical: https://rubyreferences.github.io/rubychanges/3.0.html#proc-and-eql 28 | step = ->((ctx, flow), **circuit_options) { ctx += [circuit_options[:activity]]; [Activity::Right, [ctx, flow]] } 29 | 30 | implementation = { 31 | :a => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 32 | :b => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 33 | :c => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 34 | :d => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 35 | :e => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 36 | :f => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 37 | :g => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 38 | :h => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 39 | :i => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 40 | :j => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 41 | :k => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 42 | :l => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 43 | :m => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 44 | :n => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 45 | :o => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 46 | :p => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 47 | :q => Schema::Implementation::Task(step.clone, [Activity::Output(Activity::Right, :success)], []), 48 | } 49 | 50 | # activity = Activity.new(Schema::Intermediate.(intermediate, implementation)) 51 | 52 | 53 | intermediate_new = Schema::Intermediate.new( 54 | { 55 | Schema::Intermediate::TaskRef(:a) => [Schema::Intermediate::Out(:success, :b)], 56 | Schema::Intermediate::TaskRef(:b) => [Schema::Intermediate::Out(:success, :c)], 57 | Schema::Intermediate::TaskRef(:c) => [Schema::Intermediate::Out(:success, :d)], 58 | Schema::Intermediate::TaskRef(:d) => [Schema::Intermediate::Out(:success, :e)], 59 | Schema::Intermediate::TaskRef(:f) => [Schema::Intermediate::Out(:success, :g)], 60 | Schema::Intermediate::TaskRef(:h) => [Schema::Intermediate::Out(:success, :i)], 61 | Schema::Intermediate::TaskRef(:j) => [Schema::Intermediate::Out(:success, :k)], 62 | Schema::Intermediate::TaskRef(:l) => [Schema::Intermediate::Out(:success, :m)], 63 | Schema::Intermediate::TaskRef(:n) => [Schema::Intermediate::Out(:success, :o)], 64 | Schema::Intermediate::TaskRef(:c) => [Schema::Intermediate::Out(:success, :d)], 65 | Schema::Intermediate::TaskRef(:p) => [Schema::Intermediate::Out(:success, :q)], 66 | Schema::Intermediate::TaskRef(:q) => [] 67 | }, 68 | {:q => :success}, # terminus 69 | :a # start 70 | ) 71 | 72 | 73 | 74 | 75 | Benchmark.ips do |x| 76 | x.report("Intermediate.call") { Activity.new(Schema::Intermediate.(intermediate, implementation)) } 77 | x.report("Compiler.call") { Activity.new(Schema::Intermediate::Compiler.(intermediate_new, implementation)) } 78 | # x.report("doublesplat") { doublesplat(first, kws) } 79 | 80 | x.compare! 81 | end 82 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/circuit/task_adapter.rb: -------------------------------------------------------------------------------- 1 | module Trailblazer 2 | class Activity 3 | # Circuit::TaskAdapter: Uses Circuit::Step to translate incoming, and returns a circuit-interface 4 | # compatible return set. 5 | class Circuit 6 | # Create a `Circuit::Step` instance. Mostly this is used inside a `TaskAdapter`. 7 | # 8 | # @param [callable_with_step_interface] Any kind of callable object or `:instance_method` that receives 9 | # a step interface. 10 | # @param [:option] If true, the user's callable argument is wrapped in `Trailblazer::Option`. 11 | # @return [Circuit::Step, Circuit::Step::Option] Returns a callable circuit-step. 12 | # @see https://trailblazer.to/2.1/docs/activity#activity-internals-step-interface 13 | def self.Step(callable_with_step_interface, option: false) 14 | if option 15 | return Step::Option.new(Trailblazer::Option(callable_with_step_interface), callable_with_step_interface) 16 | end 17 | 18 | Step.new(callable_with_step_interface, callable_with_step_interface) # DISCUSS: maybe we can ditch this, only if performance is cool here, but where would we use that? 19 | end 20 | 21 | # {Step#call} translates the incoming circuit-interface to the step-interface, 22 | # and returns the return value of the user's callable. By design, it is *not* circuit-interface compatible. 23 | class Step 24 | def initialize(step, user_proc, **) 25 | @step = step 26 | @user_proc = user_proc 27 | end 28 | 29 | # Translate the circuit interface to the step's step-interface. However, 30 | # this only translates the calling interface, not the returning. 31 | def call((ctx, flow_options), **circuit_options) 32 | result = @step.(ctx, **ctx.to_hash) 33 | # in an immutable environment we should return the ctx from the step. 34 | return result, ctx 35 | end 36 | 37 | # In {Step::Option}, {@step} is expected to be wrapped in a {Trailblazer::Option}. 38 | # To remember: when calling an Option instance, you need to pass {:keyword_arguments} explicitely, 39 | # because of beautiful Ruby 2.5 and 2.6. 40 | # 41 | # This is often needed for "decider" chunks where the user can run either a method or a callable 42 | # but you only want back the return value, not a Binary circuit-interface return set. 43 | class Option < Step 44 | def call((ctx, _flow_options), **circuit_options) 45 | result = @step.(ctx, keyword_arguments: ctx.to_hash, **circuit_options) # circuit_options contains :exec_context. 46 | # in an immutable environment we should return the ctx from the step. 47 | return result, ctx 48 | end 49 | end 50 | end 51 | 52 | class TaskAdapter 53 | # Returns a `TaskAdapter` instance always exposes the complete circuit-interface, 54 | # and can be used directly in a {Circuit}. 55 | # 56 | # @note This used to be called `TaskBuilder::Task`. 57 | # @param [step] Any kind of callable object or `:instance_method` that receives 58 | # a step interface. 59 | # @param [:option] If true, the user's callable argument is wrapped in `Trailblazer::Option`. 60 | # @return [TaskAdapter] a circuit-interface compatible object to use in a `Circuit`. 61 | def self.for_step(step, binary: true, **options_for_step) 62 | circuit_step = Circuit.Step(step, **options_for_step) 63 | 64 | new(circuit_step) 65 | end 66 | 67 | # @param [circuit_step] Exposes a Circuit::Step.call([ctx, flow_options], **circuit_options) interface 68 | def initialize(circuit_step, **) 69 | @circuit_step = circuit_step 70 | end 71 | 72 | def call((ctx, flow_options), **circuit_options) 73 | result, ctx = @circuit_step.([ctx, flow_options], **circuit_options) 74 | 75 | # Return an appropriate signal which direction to go next. 76 | signal = TaskAdapter.binary_signal_for(result, Activity::Right, Activity::Left) 77 | 78 | return signal, [ctx, flow_options] 79 | end 80 | 81 | # Translates the return value of the user step into a valid signal. 82 | # Note that it passes through subclasses of {Activity::Signal}. 83 | def self.binary_signal_for(result, on_true, on_false) 84 | if result.is_a?(Class) && result < Activity::Signal 85 | result 86 | else 87 | result ? on_true : on_false 88 | end 89 | end 90 | 91 | def inspect # TODO: make me private! 92 | user_step = @circuit_step.instance_variable_get(:@user_proc) # DISCUSS: to we want Step#to_h? 93 | 94 | %(#) 95 | end 96 | alias_method :to_s, :inspect 97 | end 98 | end # Circuit 99 | end # Activity 100 | end 101 | -------------------------------------------------------------------------------- /test/activity_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ActivityTest < Minitest::Spec 4 | describe "Activity#call" do 5 | it "accepts circuit interface" do 6 | flat_activity = Fixtures.flat_activity 7 | 8 | signal, (ctx, flow_options) = flat_activity.call([{seq: []}, {}]) 9 | 10 | assert_equal CU.inspect(ctx), %({:seq=>[:b, :c]}) 11 | assert_equal signal.inspect, %(#) 12 | 13 | # b step fails. 14 | signal, (ctx, flow_options) = flat_activity.call([{seq: [], b: Trailblazer::Activity::Left}, {}]) 15 | 16 | assert_equal CU.inspect(ctx), %({:seq=>[:b], :b=>Trailblazer::Activity::Left}) 17 | assert_equal signal.inspect, %(#) 18 | end 19 | 20 | it "accepts {:start_task}" do 21 | flat_activity = Fixtures.flat_activity 22 | 23 | signal, (ctx, flow_options) = flat_activity.call([{seq: []}, {}], start_task: Implementing.method(:c)) 24 | 25 | assert_equal CU.inspect(ctx), %({:seq=>[:c]}) 26 | assert_equal signal.inspect, %(#) 27 | end 28 | 29 | it "accepts {:runner}" do 30 | flat_activity = Fixtures.flat_activity 31 | 32 | my_runner = ->(task, args, **circuit_options) do 33 | args[0][:seq] << :my_runner 34 | 35 | task.(args, **circuit_options) 36 | end 37 | 38 | signal, (ctx, flow_options) = flat_activity.call([{seq: []}, {}], runner: my_runner) 39 | 40 | assert_equal CU.inspect(ctx), %({:seq=>[:my_runner, :my_runner, :b, :my_runner, :c, :my_runner]}) 41 | assert_equal signal.inspect, %(#) 42 | end 43 | 44 | it "if a signal is not connected, it throws an {IllegalSignalError} exception with helpful error message" do 45 | b_task = Implementing.method(:b) 46 | broken_activity = Fixtures.flat_activity(wiring: {b_task => {nonsense: false, bogus: true}}) # {b} task does not connect the {Right} signal. 47 | class MyExecContext; end 48 | 49 | exception = assert_raises Trailblazer::Activity::Circuit::IllegalSignalError do 50 | signal, (ctx, flow_options) = broken_activity.call([{seq: []}, {}], start_task: b_task, exec_context: MyExecContext.new) 51 | end 52 | 53 | message = "ActivityTest::MyExecContext: 54 | \e[31mUnrecognized signal `Trailblazer::Activity::Right` returned from #{b_task.inspect}. Registered signals are:\e[0m 55 | \e[32m:nonsense 56 | :bogus\e[0m" 57 | 58 | assert_equal exception.message, message 59 | 60 | assert_equal exception.task, b_task 61 | assert_equal exception.signal, Trailblazer::Activity::Right 62 | end 63 | 64 | it "automatically passes the {:activity} option" do 65 | # DISCUSS: in Ruby 3, procs created from the same block are identical: https://rubyreferences.github.io/rubychanges/3.0.html#proc-and-eql 66 | step_a = ->((ctx, flow), **circuit_options) { ctx += [circuit_options[:activity]]; [Trailblazer::Activity::Right, [ctx, flow]] } 67 | step_b = ->((ctx, flow), **circuit_options) { ctx += [circuit_options[:activity]]; [Trailblazer::Activity::Right, [ctx, flow]] } 68 | step_c = ->((ctx, flow), **circuit_options) { ctx += [circuit_options[:activity]]; [Trailblazer::Activity::Right, [ctx, flow]] } 69 | 70 | tasks = Fixtures.default_tasks("b" => step_b, "c" => step_c) 71 | 72 | flat_activity = Fixtures.flat_activity(tasks: tasks) 73 | 74 | tasks = Fixtures.default_tasks("b" => flat_activity, "c" => step_c) 75 | failure, success = flat_activity.to_h[:outputs] 76 | wiring = Fixtures.default_wiring(tasks, flat_activity => {failure.signal => tasks["End.failure"], success.signal => step_c} ) 77 | 78 | nesting_activity = Fixtures.flat_activity(tasks: tasks, wiring: wiring) 79 | 80 | _signal, (ctx,) = nesting_activity.([[], {}]) 81 | 82 | # each task receives the containing {:activity} 83 | assert_equal ctx, [flat_activity, flat_activity, nesting_activity] 84 | end 85 | end 86 | 87 | it "exposes {#to_h}" do 88 | hsh = Fixtures.flat_activity.to_h 89 | 90 | assert_equal hsh.keys, [:circuit, :outputs, :nodes, :config] # These four keys are required by the Activity interface. 91 | 92 | assert_equal hsh[:circuit].class, Trailblazer::Activity::Circuit 93 | assert_equal hsh[:outputs].collect{ |output| output.to_h[:semantic] }.inspect, %{[:failure, :success]} 94 | assert_equal hsh[:nodes].class, Trailblazer::Activity::Schema::Nodes 95 | assert_equal hsh[:nodes].collect { |id, attrs| attrs.id }.inspect, %{["Start.default", "b", "c", "End.failure", "End.success"]} 96 | 97 | assert_equal hsh[:config].inspect, "{}" 98 | end 99 | 100 | it "allows overriding {Activity.call} (this is needed in trb-pro)" do 101 | activity = Class.new(Trailblazer::Activity) 102 | 103 | call_module = Module.new do 104 | def call(*) 105 | "overridden call!" 106 | end 107 | end 108 | 109 | assert_equal activity.extend(call_module).call, "overridden call!" 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/trailblazer/activity/adds.rb: -------------------------------------------------------------------------------- 1 | module Trailblazer 2 | class Activity 3 | # Developer's docs: https://trailblazer.to/2.1/docs/internals#internals-wiring-api-adds-interface 4 | # 5 | # The Adds interface are mechanics to alter sequences/pipelines. 6 | # "one" ADDS structure: {row: ..., insert: [Insert, "id"]} 7 | # 8 | # To work with the instructions provided here, the pipeline structure 9 | # needs to expose {#to_a}. 10 | module Adds 11 | module_function 12 | 13 | # DISCUSS: Canonical entry point for pipeline altering? 14 | def call(pipeline, *inserts) 15 | instructions = FriendlyInterface.adds_for(inserts) 16 | 17 | Adds.apply_adds(pipeline, instructions) 18 | end 19 | 20 | # @returns Sequence/Pipeline New sequence instance 21 | # @private 22 | def insert_row(pipeline, row:, insert:) 23 | insert_function, *args = insert 24 | 25 | insert_function.(pipeline, row, *args) 26 | end 27 | 28 | # Inserts one or more {Adds} into {pipeline}. 29 | def apply_adds(pipeline, adds) 30 | adds.each do |add| 31 | pipeline = insert_row(pipeline, **add) 32 | end 33 | 34 | pipeline 35 | end 36 | 37 | module FriendlyInterface 38 | # @public 39 | # @return Array of ADDS 40 | # 41 | # Translate a collection of friendly interface to ADDS. 42 | # This is a mini-DSL, if you want. 43 | def self.adds_for(inserts) 44 | inserts.collect do |task, options| 45 | build_adds(task, **options) 46 | end 47 | end 48 | 49 | # @private 50 | def self.build_adds(task, **options) 51 | row, options = build_row_for(task, **options) 52 | 53 | action, insert_id = options.to_a.first # DISCUSS: maybe we can find another way to extract the DSL "insertion action" option. 54 | 55 | insert = OPTION_TO_METHOD.fetch(action) 56 | 57 | { 58 | insert: [insert, insert_id], 59 | row: row 60 | } 61 | end 62 | 63 | def self.build_row_for(task, row: nil, **options) 64 | return row, options if row 65 | 66 | return build_row(task, **options) 67 | end 68 | 69 | def self.build_row(task, id:, **options) 70 | return TaskWrap::Pipeline.Row(id, task), options 71 | end 72 | end 73 | 74 | # Functions to alter the Sequence/Pipeline by inserting, replacing, or deleting a row. 75 | # 76 | # they don't mutate the data structure but rebuild it, has to respond to {to_a} 77 | # 78 | # These methods are invoked via {Adds.apply_adds} and should never be called directly. 79 | module Insert 80 | module_function 81 | 82 | # Append {new_row} after {insert_id}. 83 | def Append(pipeline, new_row, insert_id = nil) 84 | build_from_ary(pipeline, insert_id) do |ary, index| 85 | index = ary.size if index.nil? # append to end of pipeline. 86 | 87 | range_before_index(ary, index + 1) + [new_row] + Array(ary[index + 1..-1]) 88 | end 89 | end 90 | 91 | # Insert {new_row} before {insert_id}. 92 | def Prepend(pipeline, new_row, insert_id = nil) 93 | build_from_ary(pipeline, insert_id) do |ary, index| 94 | index = 0 if index.nil? # Prepend to beginning of pipeline. 95 | 96 | range_before_index(ary, index) + [new_row] + ary[index..-1] 97 | end 98 | end 99 | 100 | def Replace(pipeline, new_row, insert_id) 101 | build_from_ary(pipeline, insert_id) do |ary, index| 102 | range_before_index(ary, index) + [new_row] + ary[index + 1..-1] 103 | end 104 | end 105 | 106 | def Delete(pipeline, _, insert_id) 107 | build_from_ary(pipeline, insert_id) do |ary, index| 108 | range_before_index(ary, index) + ary[index + 1..-1] 109 | end 110 | end 111 | 112 | # @private 113 | def build(sequence, rows) 114 | sequence.class.new(rows) 115 | end 116 | 117 | # @private 118 | def find_index(ary, insert_id) 119 | ary.find_index { |row| row.id == insert_id } 120 | end 121 | 122 | # Converts the pipeline structure to an array, 123 | # automatically finds the index for {insert_id}, 124 | # and calls the user block with the computed values. 125 | # 126 | # Single-entry point, could be named {#call}. 127 | # @private 128 | def apply_on_ary(pipeline, insert_id, raise_index_error: true, &block) 129 | ary = pipeline.to_a 130 | 131 | if insert_id.nil? 132 | index = nil 133 | else 134 | index = find_index(ary, insert_id) # DISCUSS: this only makes sense if there are more than {Append} using this. 135 | raise IndexError.new(pipeline, insert_id) if index.nil? && raise_index_error 136 | end 137 | 138 | _new_ary = yield(ary, index) # call the block. 139 | end 140 | 141 | def build_from_ary(pipeline, insert_id, &block) 142 | new_ary = apply_on_ary(pipeline, insert_id, &block) 143 | 144 | # Wrap the sequence/pipeline array into a concrete Sequence/Pipeline. 145 | build(pipeline, new_ary) 146 | end 147 | 148 | # Always returns a valid, concat-able array for all indices 149 | # before the {index}. 150 | # @private 151 | def range_before_index(ary, index) 152 | return [] if index == 0 153 | ary[0..index - 1] 154 | end 155 | end # Insert 156 | 157 | OPTION_TO_METHOD = { 158 | prepend: Insert.method(:Prepend), 159 | append: Insert.method(:Append), 160 | replace: Insert.method(:Replace), 161 | delete: Insert.method(:Delete), 162 | } 163 | 164 | class IndexError < ::IndexError 165 | def initialize(sequence, step_id) 166 | valid_ids = sequence.to_a.collect { |row| row.id.inspect } 167 | 168 | message = "\n" \ 169 | "\e[31m#{step_id.inspect} is not a valid step ID. Did you mean any of these ?\e[0m\n" \ 170 | "\e[32m#{valid_ids.join("\n")}\e[0m" 171 | 172 | super(message) 173 | end 174 | end 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /test/testing_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TestingTest < Minitest::Spec 4 | extend T.def_steps(:model) 5 | 6 | klass = Class.new do 7 | def self.persist 8 | end 9 | end 10 | 11 | class Test < Minitest::Spec 12 | def call 13 | run 14 | @failures 15 | end 16 | 17 | include Trailblazer::Activity::Testing::Assertions 18 | end 19 | 20 | it "what" do 21 | assert_equal T.render_task(TestingTest.method(:model)), %{#} 22 | assert_equal T.render_task(:model), %{model} 23 | assert_equal T.render_task(klass.method(:persist)), %{#.persist>} 24 | end 25 | 26 | 27 | 28 | it "#assert_call" do 29 | test = Class.new(Test) do 30 | let(:activity) { Fixtures.flat_activity } 31 | 32 | #0001 33 | #@ {:seq} specifies expected `ctx[:seq]`. 34 | it { assert_call activity, seq: "[:b, :c]" } 35 | 36 | #0002 37 | #@ allows {:terminus} 38 | it { assert_call activity, seq: "[:b]", terminus: :failure, b: Trailblazer::Activity::Left } 39 | 40 | #0003 41 | #@ when specifying wrong {:terminus} you get an error 42 | it { assert_call activity, seq: "[:b]", terminus: :not_right, b: Trailblazer::Activity::Left } 43 | 44 | #0004 45 | #@ when specifying wrong {:seq} you get an error 46 | it { assert_call activity, seq: "[:xxxxxx]" } 47 | 48 | #0005 49 | #@ {#assert_call} returns ctx 50 | it { 51 | ctx = assert_call activity, seq: "[:b, :c]" 52 | assert_equal Trailblazer::Core::Utils.inspect(ctx), %{{:seq=>[:b, :c]}} 53 | } 54 | 55 | #0006 56 | #@ {#assert_call} allows injecting {**ctx_variables}. 57 | it { 58 | ctx = assert_call activity, seq: "[:b, :c]", current_user: Module 59 | assert_equal Trailblazer::Core::Utils.inspect(ctx), %{{:seq=>[:b, :c], :current_user=>Module}} 60 | } 61 | end 62 | 63 | test_case = test.new(:test_0001_anonymous) 64 | failures = test_case.() 65 | assert_equal failures.size, 0 66 | 67 | test_case = test.new(:test_0002_anonymous) 68 | failures = test_case.() 69 | assert_equal failures.size, 0 70 | 71 | test_case = test.new(:test_0003_anonymous) 72 | failures = test_case.() 73 | 74 | assert_equal failures[0].message, %{assert_call expected not_right terminus, not #. Use assert_call(activity, terminus: :failure). 75 | Expected: :not_right 76 | Actual: :failure} 77 | 78 | assert_equal 1, failures.size 79 | 80 | test_case = test.new(:test_0004_anonymous) 81 | failures = test_case.() 82 | 83 | assert_equal failures[0].message, %{--- expected 84 | +++ actual 85 | @@ -1,3 +1,3 @@ 86 | # encoding: US-ASCII 87 | # valid: true 88 | -\"#{{:seq => [:xxxxxx]}}\" 89 | +\"#{{:seq=>[:b, :c]}}\" 90 | } 91 | 92 | assert_equal 1, failures.size 93 | 94 | test_case = test.new(:test_0005_anonymous) 95 | failures = test_case.() 96 | assert_equal failures.size, 0 97 | 98 | test_case = test.new(:test_0006_anonymous) 99 | failures = test_case.() 100 | assert_equal failures.size, 0 101 | end 102 | 103 | it "{:expected_ctx_variables}" do 104 | test = Class.new(Test) do 105 | let(:activity) do 106 | implementing = Module.new do 107 | # b step adding additional ctx variables. 108 | def self.b((ctx, flow_options), **) 109 | ctx[:from_b] = 1 110 | return Trailblazer::Activity::Right, [ctx, flow_options] 111 | end 112 | end 113 | 114 | tasks = Fixtures.default_tasks("b" => implementing.method(:b)) 115 | 116 | activity = Fixtures.flat_activity(tasks: tasks) 117 | end 118 | 119 | #0001 120 | #@ we can provide additional {:expected_ctx_variables}. 121 | it { assert_call activity, seq: "[:c]", expected_ctx_variables: {from_b: 1} } 122 | 123 | #0002 124 | #@ wrong {:expected_ctx_variables} fails 125 | it { assert_call activity, seq: "[:c]", expected_ctx_variables: {from_b: 2} } 126 | end 127 | 128 | test_case = test.new(:test_0001_anonymous) 129 | failures = test_case.() 130 | assert_equal failures.size, 0 131 | 132 | test_case = test.new(:test_0002_anonymous) 133 | failures = test_case.() 134 | 135 | assert_equal failures[0].message, %{--- expected 136 | +++ actual 137 | @@ -1,3 +1,3 @@ 138 | # encoding: US-ASCII 139 | # valid: true 140 | -\"#{{:seq=>[:c], :from_b=>2}}\" 141 | +\"#{{:seq=>[:c], :from_b=>1}}\" 142 | } 143 | end 144 | 145 | # assert_invoke 146 | it "#assert_invoke" do 147 | test = Class.new(Test) do 148 | class MyActivity 149 | def self.call((ctx, flow_options), **circuit_options) 150 | 151 | # ctx = ctx.merge( 152 | # ) 153 | 154 | mock_ctx = MyCtx[ 155 | **ctx, 156 | seq: ctx[:seq] + [:call], 157 | invisible: { 158 | flow_options: flow_options, 159 | circuit_options: circuit_options, 160 | } 161 | ] 162 | 163 | return Trailblazer::Activity::End.new(semantic: :success), [mock_ctx, flow_options] 164 | end 165 | end 166 | 167 | class MyCtx < Hash 168 | def inspect 169 | slice(*(keys - [:invisible])).inspect 170 | end 171 | 172 | def invisible 173 | self[:invisible] 174 | end 175 | end 176 | 177 | let(:activity) { MyActivity } 178 | 179 | #0001 180 | #@ test that we can pass {:circuit_options} 181 | it { 182 | signal, (ctx, flow_options) = assert_invoke activity, seq: "[:call]", circuit_options: {start: "yes"} 183 | 184 | assert_equal ctx.invisible[:circuit_options].keys.inspect, %([:start, :runner, :wrap_runtime, :activity]) 185 | assert_equal ctx.invisible[:circuit_options][:start], "yes" 186 | } 187 | 188 | 189 | #0002 190 | #@ test that we can pass {:flow_options} 191 | it { 192 | signal, (ctx, flow_options) = assert_invoke activity, seq: "[:call]", flow_options: {start: "yes"} 193 | 194 | assert_equal ctx.invisible[:flow_options].keys.inspect, %([:start]) 195 | assert_equal ctx.invisible[:flow_options][:start], "yes" 196 | } 197 | 198 | #0003 199 | #@ we return circuit interface 200 | it { 201 | signal, (ctx, flow_options) = assert_invoke activity, seq: "[:call]", flow_options: {start: "yes"} 202 | 203 | assert_equal signal.inspect, %(#) 204 | assert_equal Trailblazer::Core::Utils.inspect(ctx), %({:seq=>[:call]}) 205 | assert_equal Trailblazer::Core::Utils.inspect(flow_options), %({:start=>\"yes\"}) 206 | } 207 | 208 | # #0002 209 | # #@ allows {:terminus} 210 | # it { assert_call activity, seq: "[:b]", terminus: :failure, b: Trailblazer::Activity::Left } 211 | 212 | # #0003 213 | # #@ when specifying wrong {:terminus} you get an error 214 | # it { assert_call activity, seq: "[:b]", terminus: :not_right, b: Trailblazer::Activity::Left } 215 | 216 | # #0004 217 | # #@ when specifying wrong {:seq} you get an error 218 | # it { assert_call activity, seq: "[:xxxxxx]" } 219 | 220 | # #0005 221 | # #@ {#assert_call} returns ctx 222 | # it { 223 | # ctx = assert_call activity, seq: "[:b, :c]" 224 | # assert_equal Trailblazer::Core::Utils.inspect(ctx), %{{:seq=>[:b, :c]}} 225 | # } 226 | 227 | # #0006 228 | # #@ {#assert_call} allows injecting {**ctx_variables}. 229 | # it { 230 | # ctx = assert_call activity, seq: "[:b, :c]", current_user: Module 231 | # assert_equal ctx.inspect, %{{:seq=>[:b, :c], :current_user=>Module}} 232 | # } 233 | end 234 | 235 | test_case = test.new(:test_0001_anonymous) 236 | failures = test_case.() 237 | assert_equal failures.size, 0 238 | 239 | test_case = test.new(:test_0002_anonymous) 240 | failures = test_case.() 241 | assert_equal failures.size, 0 242 | 243 | test_case = test.new(:test_0003_anonymous) 244 | failures = test_case.() 245 | puts failures 246 | assert_equal failures.size, 0 247 | end 248 | end 249 | -------------------------------------------------------------------------------- /test/task_wrap_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TaskWrapTest < Minitest::Spec 4 | def wrap_static(tasks, _old_ruby_kws = {}, **options) 5 | # extensions could be used to extend a particular task_wrap. 6 | wrap_static = 7 | tasks.collect do |task| 8 | [task, Trailblazer::Activity::TaskWrap::INITIAL_TASK_WRAP] 9 | end.to_h 10 | 11 | wrap_static = wrap_static.merge(_old_ruby_kws) 12 | wrap_static = wrap_static.merge(options) 13 | end 14 | 15 | def add_1(wrap_ctx, original_args) 16 | ctx, = original_args[0] 17 | ctx[:seq] << 1 18 | return wrap_ctx, original_args # yay to mutable state. not. 19 | end 20 | 21 | def add_2(wrap_ctx, original_args) 22 | ctx, = original_args[0] 23 | ctx[:seq] << 2 24 | return wrap_ctx, original_args # yay to mutable state. not. 25 | end 26 | 27 | it "exposes {#to_h} that includes {:wrap_static}" do 28 | tasks = Fixtures.default_tasks 29 | 30 | wrap_static = wrap_static(tasks.values) 31 | 32 | hsh = Fixtures.flat_activity(tasks: tasks, config: {wrap_static: wrap_static}).to_h 33 | 34 | assert_equal hsh.keys, [:circuit, :outputs, :nodes, :config] # These four keys are required by the Trailblazer::Activity interface. 35 | assert_equal hsh[:config].keys, [:wrap_static] 36 | assert_equal hsh[:config][:wrap_static].keys, tasks.values 37 | 38 | pipeline_class = Trailblazer::Activity::TaskWrap::Pipeline 39 | call_task_inspect = [Trailblazer::Activity::TaskWrap::ROW_ARGS_FOR_CALL_TASK].inspect 40 | 41 | assert_equal hsh[:config][:wrap_static].values.collect { |value| value.class }, [pipeline_class, pipeline_class, pipeline_class, pipeline_class, pipeline_class] 42 | assert_equal hsh[:config][:wrap_static].values.collect { |value| value.to_a.inspect }, [call_task_inspect, call_task_inspect, call_task_inspect, call_task_inspect, call_task_inspect] 43 | end 44 | 45 | it "{:wrap_static} allows adding steps, e.g. via Extension" do 46 | ext = Trailblazer::Activity::TaskWrap.Extension( 47 | [method(:add_1), id: "user.add_1", prepend: "task_wrap.call_task"], 48 | [method(:add_2), id: "user.add_2", append: "task_wrap.call_task"], 49 | ) 50 | 51 | tasks = Fixtures.default_tasks 52 | _, b, c = tasks.values 53 | 54 | wrap_static = wrap_static(tasks.values) 55 | 56 | # Replace the taskWrap fo {c} with an extended one. 57 | original_tw_for_c = wrap_static[c] 58 | wrap_static = wrap_static.merge(c => ext.(original_tw_for_c)) 59 | 60 | activity = Fixtures.flat_activity(tasks: tasks, config: {wrap_static: wrap_static}) 61 | 62 | # tw is not used with normal {Trailblazer::Activity#call}. 63 | signal, (ctx, flow_options) = activity.call([{seq: []}, {}]) 64 | 65 | assert_equal CU.inspect(ctx), %({:seq=>[:b, :c]}) 66 | assert_equal signal.inspect, %(#) 67 | 68 | # With TaskWrap.invoke the tw is obviously incorporated. 69 | signal, (ctx, flow_options) = Trailblazer::Activity::TaskWrap.invoke(activity, [{seq: []}, {}]) 70 | 71 | assert_equal CU.inspect(ctx), %({:seq=>[:b, 1, :c, 2]}) 72 | assert_equal signal.inspect, %(#) 73 | end 74 | 75 | it "{:wrap_runtime} allows adding tw extensions to specific tasks when invoking the activity" do 76 | tasks = Fixtures.default_tasks 77 | 78 | activity = Fixtures.flat_activity(tasks: tasks, config: {wrap_static: wrap_static(tasks.values)}) 79 | b = tasks.fetch("b") 80 | 81 | wrap_runtime = { 82 | b => Trailblazer::Activity::TaskWrap.Extension( 83 | [method(:add_1), id: "user.add_1", prepend: "task_wrap.call_task"], 84 | [method(:add_2), id: "user.add_2", append: "task_wrap.call_task"], 85 | ) 86 | } 87 | 88 | signal, (ctx, flow_options) = Trailblazer::Activity::TaskWrap.invoke(activity, [{seq: []}, {}], wrap_runtime: wrap_runtime) 89 | 90 | assert_equal CU.inspect(ctx), %({:seq=>[1, :b, 2, :c]}) 91 | assert_equal signal.inspect, %(#) 92 | end 93 | 94 | it "{:wrap_runtime} can also be a defaulted Hash. maybe we could allow having both, default steps and specific ones?" do 95 | tasks = Fixtures.default_tasks 96 | 97 | activity = Fixtures.flat_activity(tasks: tasks, config: {wrap_static: wrap_static(tasks.values)}) 98 | 99 | wrap_runtime = Hash.new( 100 | Trailblazer::Activity::TaskWrap.Extension( 101 | [method(:add_1), id: "user.add_1", prepend: "task_wrap.call_task"], 102 | [method(:add_2), id: "user.add_2", append: "task_wrap.call_task"], 103 | ) 104 | ) 105 | 106 | signal, (ctx, flow_options) = Trailblazer::Activity::TaskWrap.invoke(activity, [{seq: []}, {}], wrap_runtime: wrap_runtime) 107 | 108 | assert_equal CU.inspect(ctx), %({:seq=>[1, 1, 2, 1, :b, 2, 1, :c, 2, 1, 2, 2]}) 109 | assert_equal signal.inspect, %(#) 110 | end 111 | 112 | def change_start_task(wrap_ctx, original_args) 113 | (ctx, flow_options), circuit_options = original_args 114 | 115 | circuit_options = circuit_options.merge(start_task: ctx[:start_at]) 116 | original_args = [[ctx, flow_options], circuit_options] 117 | 118 | return wrap_ctx, original_args 119 | end 120 | 121 | # Set start_task of {flat_activity} (which is nested in {nesting_activity}) to something else than configured in the 122 | # nested activity itself. 123 | # Instead of running {a -> {b -> c} -> success} it now goes {a -> {c} -> success}. 124 | it "allows changing {:circuit_options} via a taskWrap step, but only locally" do 125 | flat_tasks = Fixtures.default_tasks 126 | 127 | flat_wrap_static = wrap_static(flat_tasks.values) 128 | 129 | flat_activity = Fixtures.flat_activity(tasks: flat_tasks, config: {wrap_static: flat_wrap_static}) 130 | 131 | tasks = Fixtures.default_tasks("c" => flat_activity) 132 | 133 | # Replace the taskWrap fo {flat_activity} with an extended one. 134 | ext = Trailblazer::Activity::TaskWrap.Extension( 135 | [method(:change_start_task), id: "my.change_start_task", prepend: nil], 136 | ) 137 | 138 | wrap_static = wrap_static( 139 | tasks.values, 140 | flat_activity => ext.(Trailblazer::Activity::TaskWrap::INITIAL_TASK_WRAP), # extended. 141 | ) 142 | 143 | failure, success = flat_activity.to_h[:outputs] 144 | wiring = Fixtures.default_wiring(tasks, flat_activity => {failure.signal => tasks["End.failure"], success.signal => tasks["End.success"]} ) 145 | activity = Fixtures.flat_activity(tasks: tasks, wiring: wiring, config: {wrap_static: wrap_static}) 146 | 147 | # Note the custom user-defined {:start_at} circuit option. 148 | signal, (ctx, flow_options) = Trailblazer::Activity::TaskWrap.invoke(activity, [{seq: [], start_at: flat_tasks.fetch("c")}, {}]) 149 | 150 | # We run activity.c, then only flat_activity.c as we're skipping the inner {b} step. 151 | assert_equal CU.inspect(ctx), %({:seq=>[:b, :c], :start_at=>#{flat_tasks.fetch("c").inspect}}) 152 | assert_equal signal.inspect, %(#) 153 | end 154 | 155 | def change_circuit_options(wrap_ctx, original_args) 156 | (ctx, flow_options), circuit_options = original_args 157 | 158 | circuit_options.merge!( # DISCUSS: do this like an adult. 159 | this_is_only_visible_in_this_very_step: true 160 | ) 161 | 162 | return wrap_ctx, original_args 163 | end 164 | 165 | # DISCUSS: maybe we can set the {:runner} in a tw step and then check that only the "current" task is affected? 166 | it "when changing {circuit_options}, it can only be seen in that very step. The following step sees the original {circuit_options}" do 167 | # trace {circuit_options} in {c}. 168 | step_c = ->((ctx, flow_options), **circuit_options) { ctx[:circuit_options] = circuit_options.keys; [Trailblazer::Activity::Right, [ctx, flow_options]] } 169 | 170 | tasks = Fixtures.default_tasks("c" => step_c) 171 | 172 | # in {b}'s taskWrap, we tamper with {circuit_options}. 173 | ext = Trailblazer::Activity::TaskWrap.Extension( 174 | [method(:change_circuit_options), id: "my.change_circuit_options", prepend: nil], 175 | ) 176 | 177 | wrap_static = wrap_static( 178 | tasks.values, 179 | tasks.fetch("b") => ext.(Trailblazer::Activity::TaskWrap::INITIAL_TASK_WRAP), # extended. 180 | ) 181 | 182 | activity = Fixtures.flat_activity(tasks: tasks, config: {wrap_static: wrap_static}) 183 | 184 | signal, (ctx, flow_options) = Trailblazer::Activity::TaskWrap.invoke(activity, [{seq: []}, {}], key_for_circuit_options: true) 185 | 186 | # We run activity.c, then only flat_activity.c as we're skipping the inner {b} step. 187 | assert_equal CU.inspect(ctx), %({:seq=>[:b], :circuit_options=>[:key_for_circuit_options, :wrap_runtime, :activity, :runner]}) 188 | assert_equal signal.inspect, %(#) 189 | end 190 | 191 | describe "{TaskWrap.container_activity_for}" do 192 | it "accepts {:wrap_static} option" do 193 | host_activity = Trailblazer::Activity::TaskWrap.container_activity_for(Object, wrap_static: {a: 1}) 194 | 195 | assert_equal CU.inspect(host_activity), "{:config=>{:wrap_static=>{Object=>{:a=>1}}}, :nodes=>{Object=>#}}" 196 | end 197 | 198 | it "if {:wrap_static} not given it adds {#initial_wrap_static}" do 199 | host_activity = Trailblazer::Activity::TaskWrap.container_activity_for(Object) 200 | 201 | assert_equal CU.inspect(host_activity), "{:config=>{:wrap_static=>{Object=>#{Trailblazer::Activity::TaskWrap.initial_wrap_static.inspect}}}, :nodes=>{Object=>#}}" 202 | end 203 | 204 | it "accepts additional options for {:config}, e.g. {each: true}" do 205 | host_activity = Trailblazer::Activity::TaskWrap.container_activity_for(Object, each: true) 206 | 207 | assert_equal CU.inspect(host_activity), "{:config=>{:wrap_static=>{Object=>#{Trailblazer::Activity::TaskWrap.initial_wrap_static.inspect}}, :each=>true}, :nodes=>{Object=>#}}" 208 | 209 | # allows mixing 210 | host_activity = Trailblazer::Activity::TaskWrap.container_activity_for(Object, each: true, wrap_static: {a: 1}) 211 | 212 | assert_equal CU.inspect(host_activity), "{:config=>{:wrap_static=>{Object=>{:a=>1}}, :each=>true}, :nodes=>{Object=>#}}" 213 | end 214 | 215 | it "accepts {:id}" do 216 | host_activity = Trailblazer::Activity::TaskWrap.container_activity_for(Object, id: :OBJECT) 217 | 218 | assert_equal CU.inspect(host_activity), "{:config=>{:wrap_static=>{Object=>#{Trailblazer::Activity::TaskWrap.initial_wrap_static.inspect}}}, :nodes=>{Object=>#}}" 219 | end 220 | end # {TaskWrap.container_activity_for} 221 | end 222 | -------------------------------------------------------------------------------- /test/adds_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class AddsTest < Minitest::Spec 4 | # DISCUSS: not tested here is Append to empty Pipeline because we always initialize it. 5 | let(:pipeline) { Trailblazer::Activity::TaskWrap::Pipeline } 6 | let(:adds) { Trailblazer::Activity::Adds } 7 | 8 | #@ No mutation on original pipe 9 | #@ Those tests are written in one single {it} on purpose. Further on, we perform all ADDS operations 10 | #@ before we assert the particular pipelines to test if anything gets mutated during the way. 11 | 12 | # Canonical top-level API 13 | it "what" do 14 | pipe1 = pipeline.new([pipeline::Row["task_wrap.call_task", "task, call"]]) 15 | 16 | #@ {Prepend} to element 0 17 | pipe2 = adds.(pipe1, ["trace, prepare", prepend: "task_wrap.call_task", id: "trace-in-outer"]) 18 | 19 | #@ {Append} to element 0 20 | pipe3 = adds.(pipe2, ["trace, prepare", append: "task_wrap.call_task", id: "trace-out-outer"]) 21 | 22 | #@ {Prepend} again 23 | pipe4 = adds.(pipe3, ["trace, prepare", prepend: "task_wrap.call_task", id: "trace-in-inner"]) 24 | 25 | #@ {Append} again 26 | pipe5 = adds.(pipe4, ["trace, prepare", append: "task_wrap.call_task", id: "trace-out-inner"]) 27 | 28 | #@ {Append} to last element 29 | pipe6 = adds.(pipe5, ["log", append: "trace-out-outer", id: "last-id"]) 30 | 31 | #@ {Replace} first element 32 | pipe7 = adds.(pipe6, ["log", replace: "trace-in-outer", id: "first-element"]) 33 | 34 | #@ {Replace} last element 35 | pipe8 = adds.(pipe7, ["log", replace: "last-id", id: "last-element"]) 36 | 37 | #@ {Replace} middle element 38 | pipe9 = adds.(pipe8, ["log", replace: "trace-out-outer", id: "middle-element"]) 39 | 40 | #@ {Delete} first element 41 | pipe10 = adds.(pipe9, ["log", delete: "first-element", id: nil]) 42 | 43 | #@ {Delete} last element 44 | pipe11 = adds.(pipe10, ["log", delete: "last-element", id: nil]) 45 | 46 | #@ {Delete} middle element 47 | pipe12 = adds.(pipe11, [nil, delete: "trace-out-inner", id: nil]) 48 | 49 | assert_equal inspect(pipe1), %{# 51 | } 52 | 53 | assert_equal inspect(pipe2), %{# 57 | } 58 | 59 | assert_equal inspect(pipe3), %{# 64 | } 65 | 66 | assert_equal inspect(pipe4), %{# 72 | } 73 | 74 | assert_equal inspect(pipe5), %{# 81 | } 82 | 83 | assert_equal inspect(pipe6), %{# 91 | } 92 | 93 | assert_equal inspect(pipe7), %{# 101 | } 102 | 103 | assert_equal inspect(pipe8), %{# 111 | } 112 | 113 | assert_equal inspect(pipe9), %{# 121 | } 122 | 123 | assert_equal inspect(pipe10), %{# 130 | } 131 | 132 | assert_equal inspect(pipe11), %{# 138 | } 139 | 140 | assert_equal inspect(pipe12), %{# 145 | } 146 | end 147 | 148 | # Internal API, currently used in dsl, too. 149 | it "what" do 150 | pipe1 = pipeline.new([pipeline::Row["task_wrap.call_task", "task, call"]]) 151 | 152 | #@ {Prepend} to element 0 153 | pipe2 = adds.(pipe1, ["trace, prepare", prepend: "task_wrap.call_task", id: "trace-in-outer"]) 154 | 155 | #@ {Append} to element 0 156 | pipe3 = adds.(pipe2, ["trace, prepare", append: "task_wrap.call_task", id: "trace-out-outer"]) 157 | 158 | #@ {Prepend} again 159 | pipe4 = adds.(pipe3, ["trace, prepare", prepend: "task_wrap.call_task", id: "trace-in-inner"]) 160 | 161 | #@ {Append} again 162 | pipe5 = adds.(pipe4, ["trace, prepare", append: "task_wrap.call_task", id: "trace-out-inner"]) 163 | 164 | #@ {Append} to last element 165 | pipe6 = adds.(pipe5, ["log", append: "trace-out-outer", id: "last-id"]) 166 | 167 | #@ {Replace} first element 168 | pipe7 = adds.(pipe6, ["log", replace: "trace-in-outer", id: "first-element"]) 169 | 170 | #@ {Replace} last element 171 | pipe8 = adds.(pipe7, ["log", replace: "last-id", id: "last-element"]) 172 | 173 | #@ {Replace} middle element 174 | pipe9 = adds.(pipe8, ["log", replace: "trace-out-outer", id: "middle-element"]) 175 | 176 | #@ {Delete} first element 177 | pipe10 = adds.(pipe9, ["log", delete: "first-element", id: nil]) 178 | 179 | #@ {Delete} last element 180 | pipe11 = adds.(pipe10, ["log", delete: "last-element", id: nil]) 181 | 182 | #@ {Delete} middle element 183 | pipe12 = adds.(pipe11, [nil, delete: "trace-out-inner", id: nil]) 184 | 185 | assert_equal inspect(pipe1), %{# 187 | } 188 | 189 | assert_equal inspect(pipe2), %{# 193 | } 194 | 195 | assert_equal inspect(pipe3), %{# 200 | } 201 | 202 | assert_equal inspect(pipe4), %{# 208 | } 209 | 210 | assert_equal inspect(pipe5), %{# 217 | } 218 | 219 | assert_equal inspect(pipe6), %{# 227 | } 228 | 229 | assert_equal inspect(pipe7), %{# 237 | } 238 | 239 | assert_equal inspect(pipe8), %{# 247 | } 248 | 249 | assert_equal inspect(pipe9), %{# 257 | } 258 | 259 | assert_equal inspect(pipe10), %{# 266 | } 267 | 268 | assert_equal inspect(pipe11), %{# 274 | } 275 | 276 | assert_equal inspect(pipe12), %{# 281 | } 282 | end 283 | 284 | it "{.call} allows passing a {:row} option per instruction, which implies omitting the {:id}" do 285 | pipe1 = pipeline.new([pipeline::Row["task_wrap.call_task", "task, call"]]) 286 | my_row_class = Class.new(Array) do 287 | def id 288 | "my id" 289 | end 290 | end 291 | 292 | pipe2 = adds.(pipeline.new([]), [nil, prepend: nil, row: my_row_class[1,2,3]]) 293 | 294 | assert_equal pipe2.to_a.collect { |row| row.class }, [my_row_class] 295 | end 296 | 297 | it "raises when {:id} is omitted and no {:row} passed" do 298 | exception = assert_raises do 299 | pipe1 = adds.(pipeline.new([]), [Object, prepend: nil]) 300 | end 301 | 302 | assert_equal exception.message.gsub(":", ""), %(missing keyword id) 303 | end 304 | 305 | it "{Append} without ID on empty list" do 306 | pipe = pipeline.new([]) 307 | 308 | add = { insert: [adds::Insert.method(:Append)], row: pipeline::Row["laster-id", "log"] } 309 | pipe1 = adds.apply_adds(pipe, [add]) 310 | 311 | assert_equal inspect(pipe1), %{# 313 | } 314 | end 315 | 316 | let(:one_element_pipeline) { pipeline.new([pipeline::Row["task_wrap.call_task", "task, call"]]) } 317 | 318 | it "{Append} on 1-element list" do 319 | add = { insert: [adds::Insert.method(:Append), "task_wrap.call_task"], row: pipeline::Row["laster-id", "log"] } 320 | pipe1 = adds.apply_adds(one_element_pipeline, [add]) 321 | 322 | assert_equal inspect(pipe1), %{# 324 | } 325 | end 326 | 327 | it "{Replace} on 1-element list" do 328 | add = { insert: [adds::Insert.method(:Replace), "task_wrap.call_task"], row: pipeline::Row["laster-id", "log"] } 329 | pipe1 = adds.apply_adds(one_element_pipeline, [add]) 330 | 331 | assert_equal inspect(pipe1), %{# 333 | } 334 | end 335 | 336 | it "{Delete} on 1-element list" do 337 | add = { insert: [adds::Insert.method(:Delete), "task_wrap.call_task"], row: nil } 338 | pipe1 = adds.apply_adds(one_element_pipeline, [add]) 339 | 340 | assert_equal inspect(pipe1), %{# 341 | } 342 | end 343 | 344 | it "{Prepend} without ID on empty list" do 345 | pipe = pipeline.new([]) 346 | 347 | add = { insert: [adds::Insert.method(:Prepend)], row: pipeline::Row["laster-id", "log"] } 348 | pipe1 = adds.apply_adds(pipe, [add]) 349 | 350 | assert_equal inspect(pipe1), %{# 352 | } 353 | end 354 | 355 | it "{Prepend} without ID on 1-element list" do 356 | add = { insert: [adds::Insert.method(:Prepend)], row: pipeline::Row["laster-id", "log"] } 357 | pipe1 = adds.apply_adds(one_element_pipeline, [add]) 358 | 359 | assert_equal inspect(pipe1), %{# 361 | } 362 | end 363 | 364 | it "throws an Adds::Sequence error when ID non-existant" do 365 | pipe = pipeline.new([pipeline::Row["task_wrap.call_task", "task, call"], pipeline::Row["task_wrap.log", "task, log"]]) 366 | 367 | #@ {Prepend} to element that doesn't exist 368 | add = { insert: [adds::Insert.method(:Prepend), "NOT HERE!"], row: pipeline::Row["trace-in-outer", "trace, prepare"] } 369 | 370 | exception = assert_raises Trailblazer::Activity::Adds::IndexError do 371 | adds.apply_adds(pipe, [add]) 372 | end 373 | 374 | assert_equal exception.message, %{ 375 | \e[31m\"NOT HERE!\" is not a valid step ID. Did you mean any of these ?\e[0m 376 | \e[32m\"task_wrap.call_task\" 377 | \"task_wrap.log\"\e[0m} 378 | end 379 | 380 | def inspect(pipe) 381 | pipe.pretty_inspect.sub(/0x\w+/, "") 382 | end 383 | end 384 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # 0.18.0 2 | 3 | * Allow `:replace` in ADDS friendly interface. 4 | * `TaskWrap::INITIAL_wRAP_STATIC` is now `::INITIAL_TASK_WRAP`. 5 | * Remove `TaskWrap::Extension::WrapStatic`. We only use `TaskWrap::Extension` now. Note that presently, you 6 | cannot add options to the `config` field anymore via an extension. 7 | * Internals: we don't use `Compiler` in tests anymore as this is a concept moved to `trailblazer-workflow`. 8 | * Remove `Trailblazer::Activity::TaskBuilder.Binary()` deprecation.` 9 | * Introduce `Adds.call` as the canonical entry point for altering pipelines. 10 | 11 | # 0.17.0 12 | 13 | * In `Extension#call`, remove the dependency to `Implementation::Task`. The logic only receives the actual circuit task. 14 | * Remove deprecated `Extension()` interface. 15 | * Remove `Trailblazer::Activity::Introspect::Graph`. 16 | 17 | # 0.16.4 18 | 19 | * Revert the static "fix" from 0.16.3 and make the compaction fix optional, as most Ruby versions 20 | are solving this problem natively. See here for details: https://dev.to/trailblazer/gc-compaction-behold-your-hash-keys-2ii1#quick-fix 21 | 22 | # 0.16.3 23 | 24 | * Fix a bug in Ruby 3.2: `NoMethodError: undefined method `[]' for nil`. Thanks @tiagotex for finding the problem 25 | and solution. This bug only occurs when compacting the memory using GC, which is only introduced with Ruby 3.2. 26 | 27 | # 0.16.2 28 | 29 | * Allow passing custom `:flow_options` to `Testing#assert_invoke`. 30 | 31 | # 0.16.1 32 | 33 | * Allow overriding `Activity.call`. 34 | 35 | # 0.16.0 36 | 37 | * Remove `Activity#[]`. Please use `activity.to_h[:config]`. 38 | * Change `Activity#to_h[:nodes]`. This is now a `Schema::Nodes` "hash" that is keyed by task that 39 | points to `Nodes::Attributes` data structures (a replacement for `Activity::NodeAttributes`). 40 | This decision reduces logic and improves performance: it turned out that most of the time an introspect 41 | lookup queries for a task, not ID. 42 | * Introduce `Activity::Introspect.Nodes()` as a consistent and fast interface for introspection 43 | and remove `Activity::Introspect::TaskMap`. 44 | * Remove `Activity::NodeAttributes`. 45 | * Move `Introspect::Graph` to `trailblazer-developer`. It's a data structure very specific 46 | to rendering, which is not a part of pure runtime behavior. `Activity::Introspect.Graph()` is now deprecated. 47 | * `TaskWrap.container_activity_for` now accepts `:id` for setting an ID for the containered activity to 48 | anything other than `nil`. 49 | * Re-add `:nodes` to the container activity hash as this provides a consistent way for treating all `Activity`s. 50 | * Remove `Activity::Config`. This immutable hash interface was used in one place, only, and can easily 51 | be replaced with `config.merge()`. 52 | * Add `Introspect::Render`. Please consider this private. 53 | 54 | ## Intermediate/Implementation 55 | 56 | * Remove `Intermediate.call`, this is now done through `Intermediate::Compiler`. 57 | * Introduce `Intermediate::Compiler` which is simplified and is 10% faster. 58 | * A terminus ("end event") in `Schema::Intermediate` no longer has outputs but an empty array. The 59 | `stop_event: true` option is still required to mark the `TaskRef` as a terminus. 60 | * `Schema::Intermediate` now keeps a map `{ => :semantic}` instead of the flat termini ID list and 61 | one default start event instead of an array. This looks as follows. 62 | 63 | ```ruby 64 | Schema::Intermediate.new( 65 | { 66 | # ... 67 | Intermediate::TaskRef("End.success", stop_event: true) => [Inter::Out(:success, nil)] 68 | }, 69 | ["End.success"], 70 | [:a] # start 71 | ``` 72 | 73 | Now becomes 74 | 75 | ```ruby 76 | Schema::Intermediate.new( 77 | { 78 | # ... 79 | Intermediate::TaskRef("End.success", stop_event: true) => [] 80 | }, 81 | {"End.success" => :success}, 82 | :a # start 83 | ``` 84 | * In line with the change in `Intermediate`, the `Implemention` termini `Task`s now don't have outputs anymore. 85 | 86 | ```ruby 87 | implementation = { 88 | # ... 89 | "End.success" => Schema::Implementation::Task(Activity::End.new(semantic: :success), [], []) # No need for outputs here. 90 | } 91 | ``` 92 | 93 | # 0.15.1 94 | 95 | * Introduce `Extension.WrapStatic()` as a consistent interface for creating wrap_static extensions 96 | exposing the friendly interface. 97 | * Deprecate `Extension(merge: ...)` since we have `Extension.WrapStatic` now. 98 | * Better deprecation warnings for extensions using `Insert` and not the friendly interface. 99 | 100 | # 0.15.0 101 | 102 | * Rename `Circuit::Run` to `Circuit::Runner` for consistency with `TaskWrap::Runner`. 103 | * Add `:flat_activity` keyword argument to `Testing.nested_activity`, so you can inject any activity for `:D`. 104 | Also, allow to change `:D`'s ID with the `:d_id` option. 105 | * Introduce `Deprecate.warn` to have consistent deprecation warnings across all gems. 106 | * Introduce `Activity.call` as a top-level entry point and abstraction for `TaskWrap.invoke`. 107 | 108 | ## TaskWrap 109 | 110 | * Remove the `:wrap_static` keyword argument for `TaskWrap.invoke` and replace it with `:container_activity`. 111 | * Make `TaskWrap.initial_wrap_static` return `INITIAL_TASK_WRAP` instead of recompiling it for every `#invoke`. 112 | * Introduce `TaskWrap.container_activity_for` to build "host activities" that are used to provide a wrap_static to 113 | the actually run activity. This is also used in the `Each()` macro and other places. 114 | * Allow `append: nil` for friendly interface. 115 | 116 | ```ruby 117 | TaskWrap.Extension([method(:add_1), id: "user.add_1", append: nil])`. 118 | ``` 119 | This appends the step to the end of the pipeline. 120 | 121 | ## Introspect 122 | 123 | * Add `Introspect.find_path` to retrieve a `Graph::Node` and its hosting activity from a deeply nested graph. 124 | Note that this method is still considered private. 125 | * Add `Introspect::TaskMap` as a slim interface for introspecting `Activity` instances. Note that `Graph` might get 126 | moved to `developer` as it is very specific to rendering circuits. 127 | 128 | ## TaskAdapter 129 | 130 | * Rename `Activity::TaskBuilder.Binary()` to `Activity::Circuit::TaskAdapter.for_step()`. It returns a `TaskAdapter` 131 | instance, which is a bit more descriptive than a `Task` instance. 132 | * Add `Circuit.Step(callable_with_step_interface)` which accepts a step-interface callable and, when called, returns 133 | its result and the `ctx`. This is great for deciders in macros where you don't want the step's result on the `ctx`. 134 | * Add `TaskWrap::Pipeline::TaskBuilder`. 135 | 136 | # 0.14.0 137 | 138 | * Remove `Pipeline.insert_before` and friends. Pipeline is now altered using ADDS mechanics, just 139 | as we do it with the `Sequence` in the `trailblazer-activity-dsl-linear` gem. 140 | * `Pipeline::Merge` is now `TaskWrap::Extension`. The "pre-friendly interface" you used to leverage for creating 141 | taskWrap (tw) extensions is now deprecated and you will see warnings. See https://trailblazer.to/2.1/docs/activity.html#activity-taskwrap-extension 142 | * Replace `TaskWrap::Extension()` with `TaskWrap::Extension.WrapStatic()` as a consistent interface for creating tW extensions at compile-time. 143 | * Remove `Insert.find`. 144 | * Rename `Activity::State::Config` to `Activity::Config`. 145 | * Move `VariableMapping` to the `trailblazer-activity-dsl-linear` gem. 146 | * Move `Pipeline.prepend` to the `trailblazer-activity-linear-dsl` gem. 147 | * Add `Testing#assert_call` as a consistent test implementation. (0.14.0.beta2) 148 | 149 | # 0.13.0 150 | 151 | * Removed `TaskWrap::Inject::Defaults`. This is now implemented through `dsl`'s `:inject` option. 152 | * Removed `TaskWrap::VariableMapping.Extension`. 153 | * Renamed private `TaskWrap::VariableMapping.merge_for` to `.merge_instructions_for` as there's no {Merge} instance, yet. 154 | * Extract invocation logic in `TaskBuilder::Task` into `Task#call_option`. 155 | * Add `TaskWrap::Pipeline::prepend`. 156 | 157 | # 0.12.2 158 | 159 | * Use extracted `trailblazer-option`. 160 | 161 | # 0.12.1 162 | 163 | * Allow injecting `:wrap_static` into `TaskWrap.invoke`. 164 | 165 | # 0.12.0 166 | 167 | * Support for Ruby 3.0. 168 | 169 | # 0.11.5 170 | 171 | * Bug fix: `:output` filter from `TaskWrap::VariableMapping` wasn't returning the correct `flow_options`. If the wrapped task changed 172 | its `flow_options`, the original one was still returned from the taskWrap run, not the updated one. 173 | 174 | # 0.11.4 175 | 176 | * Introduce the `config_wrap:` option in `Intermediate.call(intermediate, implementation, config_merge: {})` to allow injecting data into the activity's `:config` field. 177 | 178 | # 0.11.3 179 | 180 | * Allow `Testing.def_task` & `Testing.def_tasks` to return custom signals 181 | 182 | # 0.11.2 183 | 184 | * Upgrading `trailblazer-context` version :drum: 185 | 186 | # 0.11.1 187 | 188 | * Internal warning fixes. 189 | 190 | # 0.11.0 191 | 192 | * Support for Ruby 2.7. Most warnings are gone. 193 | 194 | # 0.10.1 195 | 196 | * Update IllegalSignalError exception for more clarity 197 | 198 | # 0.10.0 199 | 200 | * Require `developer` >= 0.0.7. 201 | * Move `Activity::Introspect` back to this very gem. 202 | * This is hopefully the last release before 2.1.0. :trollface: 203 | 204 | # 0.9.4 205 | 206 | * Move some test helpers to `Activity::Testing` to export them to other gems 207 | * Remove introspection modules, it'll also be part of the `Dev` tools now. 208 | * Remove tracing modules, it'll be part of the `Dev` tools now. 209 | 210 | # 0.9.3 211 | 212 | Unreleased. 213 | 214 | # 0.9.2 215 | 216 | Unreleased. 217 | 218 | # 0.9.1 219 | 220 | * Use `context-0.9.1`. 221 | 222 | # 0.9.0 223 | 224 | * Change API of `Input`/`Output` filters. Instead of calling them with `original_ctx, circuit_options` they're now called with the complete (original!) circuit interface. This simplifies calling and provides all circuit arguments to the filter which can then filter-out what is not needed. 225 | * `:input` is now called with `((original_ctx, flow_options), circuit_options)` 226 | * `:output` is now called with `(new_ctx, (original_ctx, flow_options), circuit_options)` 227 | 228 | # 0.8.4 229 | * Update `Present` to render `Developer.wtf?` for given activity fearlessly 230 | 231 | # 0.8.3 232 | 233 | * Use `Context.for` to create contexts. 234 | 235 | # 0.8.2 236 | 237 | * Fix `Present` so it works with Ruby <= 2.3. 238 | 239 | # 0.8.1 240 | 241 | * Remove `hirb` gem dependency. 242 | 243 | # 0.8.0 244 | 245 | * Separate the [DSL](https://github.com/trailblazer/trailblazer-activity-dsl-linear) from the runtime code. The latter sits in this gem. 246 | * Separate the runtime {Activity} from its compilation, which happens through {Intermediate} (the structure) and {Implementation} (the runtime implementation) now. 247 | * Introduce {Pipeline} which is a simpler and much fast type of activity, mostly for the taskWrap. 248 | 249 | # 0.7.2 250 | 251 | * When recording DSL calls, use the `object_id` as key, so cloned methods are considered as different recordings. 252 | 253 | # 0.7.1 254 | 255 | * Alias `Trace.call` to `Trace.invoke` for consistency. 256 | * Allow injecting your own stack into `Trace.invoke`. This enables us to provide tracing even when there's an exception (due to, well, mutability). 257 | * Minor changes in `Trace::Present` so that "unfinished" stacks can also be rendered. 258 | * `Trace::Present.tree` is now private and superseded by `Present.call`. 259 | 260 | # 0.7.0 261 | 262 | * Remove `DSL::Helper`, "helper" methods now sit directly in the `DSL` namespace. 263 | 264 | # 0.6.2 265 | 266 | * Allow all `Option` types for input/output. 267 | 268 | # 0.6.1 269 | 270 | * Make `:input` and `:output` standard options of the DSL to create variable mappings. 271 | 272 | # 0.6.0 273 | 274 | * The `:task` option in `Circuit::call` is now named `:start_task` for consistency. 275 | * Removed the `:argumenter` option for `Activity::call`. Instead, an `Activity` passes itself via the `:activity` option. 276 | * Removed the `:extension` option. Instead, any option from the DSL that `is_a?(DSL::Extension)` will be processed in `add_task!`. 277 | * Replace argumenters with `TaskWrap::invoke`. This simplifies the whole `call` process, and moves all initialization of args to the top. 278 | * Added `Introspect::Graph::find`. 279 | * Removed `Introspect::Enumerator` in favor of the `Graph` API. 280 | 281 | # 0.5.4 282 | 283 | * Introducing `Introspect::Enumerator` and removing `Introspect.find`. `Enumerator` contains `Enumerable` and exposes all necessary utility methods. 284 | 285 | # 0.5.3 286 | 287 | * In Path(), allow referencing an existing task, instead of creating an end event. 288 | This avoids having to use two `Output() => ..` and is much cleaner. 289 | 290 | ```ruby 291 | Path( end_id: :find_model) do .. end 292 | ``` 293 | 294 | # 0.5.2 295 | 296 | * In `Path()`, we removed the `#path` method in favor of a cleaner `task` DSL method. We now use the default plus_poles `success` and `failure` everywhere for consistency. This means that a `task` has two outputs, and if you referenced `Output(:success)`, that would be only one of them. We're planning to have `pass` back which has one `success` plus_pole, only. This change makes the DSL wiring behavior much more consistent. 297 | * Changed `TaskBuilder::Builder.()` to a function `TaskBuilder::Builder()`. 298 | 299 | # 0.5.1 300 | 301 | * Include all end events without outgoing connections into `Activity.outputs`. In earlier versions, we were filtering out end events without incoming connections, which reduces the number of outputs, but might not represent the desired interface of an activity. 302 | * Add `_end` to `Railway` and `FastTrack`. 303 | * Move `Builder::FastTrack::PassFast` and `:::FailFast` to `Activity::FastTrack` since those are signals and unrelated to builders. 304 | 305 | # 0.5.0 306 | 307 | * Rename `Nested()` to `Subprocess` and move the original one to the `operation` gem. 308 | * Add merging: `Activity.merge!` now allows to compose an activity by merging another. 309 | * Enforce using `Output(..) => Track(:success)` instead of just the track color `:success`. This allow having IDs both symbols and strings. 310 | 311 | # 0.4.3 312 | 313 | * Make `:outputs` the canonical way to define outputs, and not `:plus_poles`. The latter is computed by the DSL if not passed. 314 | * Allow injecting `inspect` implementations into `Introspect` methods. 315 | * Add `Nested`. 316 | * Add `TaskBuilder::Task#to_s`. 317 | 318 | # 0.4.2 319 | 320 | * `End` is not a `Struct` so we can maintain more state, and are immutable. 321 | 322 | # 0.4.1 323 | 324 | * Remove `decompose` and replace it with a better `to_h`. 325 | * `End` doesn't have a redundant `@name` anymore but only a semantic. 326 | 327 | # 0.4.0 328 | 329 | * We now use the "Module Subclass" pattern, and activities aren't classes anymore but modules. 330 | 331 | # 0.3.2 332 | 333 | * In the `TaskWrap`, rename `:result_direction` to `:return_signal` and `:result_args` to `:return_args`, 334 | 335 | # 0.3.1 336 | 337 | * Allow passing a `:normalizer` to the DSL. 338 | * Builders don't have to provide `keywords` as we can filter them automatically. 339 | 340 | # 0.2.2 341 | 342 | * Remove `Activity#end_events`. 343 | 344 | # 0.2.1 345 | 346 | * Restructure all `Wrap`-specific tasks. 347 | * Remove `Hash::Immutable`, we will use the `hamster` gem instead. 348 | 349 | # 0.2.0 350 | 351 | * The `Activity#call` API is now 352 | 353 | ```ruby 354 | signal, options, _ignored_circuit_options = Activity.( options, **circuit_options ) 355 | ``` 356 | 357 | The third return value, which is typically the `circuit_options`, is _ignored_ and for all task calls in this `Activity`, an identical, unchangeable set of `circuit_options` is passed to. This dramatically reduces unintended behavior with the task_wrap, tracing, etc. and usually simplifies tasks. 358 | 359 | The new API allows using bare `Activity` instances as tasks without any clumsy nesting work, making nesting very simple. 360 | 361 | A typical task will look as follows. 362 | 363 | ```ruby 364 | ->( (options, flow_options), **circuit_args ) do 365 | [ signal, [options, flow_options], *this_will_be_ignored ] 366 | end 367 | ``` 368 | 369 | A task can only emit a signal and "options" (whatever data structure that may be), and can *not* change the `circuit_options` which usually contain activity-wide "global" configuration. 370 | 371 | 372 | 373 | # 0.1.6 374 | 375 | * `Nested` now is `Subprocess` because it literally does nothing else but calling a _process_ (or activity). 376 | 377 | # 0.1.5 378 | 379 | # 0.1.4 380 | 381 | * `Nested` now uses kw args for `start_at` and the new `call` option. The latter allows to set the called method on the nested activity, e.g. `__call`. 382 | 383 | # 0.1.3 384 | 385 | * Introduce `Activity#outputs` and ditch `#end_events`. 386 | 387 | # 0.1.2 388 | 389 | * Consistent return values for all graph operations: `node, edge`. 390 | * `Edge` now always gets an id. 391 | * `#connect_for!` always throws away the old edge, fixing a bug where graph and circuit would look different. 392 | * Internal simplifications for `Graph` alteration methods. 393 | 394 | # 0.1.1 395 | 396 | * Fix loading order. 397 | 398 | # 0.0.12 399 | 400 | * In `Activity::Before`, allow specifying what predecessing tasks to connect to the new_task via the 401 | `:predecessors` option, and without knowing the direction. This will be the new preferred style in `Trailblazer:::Sequence` 402 | where we can always assume directions are limited to `Right` and `Left` (e.g., with nested activities, this changes to a 403 | colorful selection of directions). 404 | 405 | # 0.0.11 406 | 407 | * Temporarily allow injecting a `to_hash` transformer into a `ContainerChain`. This allows to ignore 408 | certain container types such as `Dry::Container` in the KW transformation. Note that this is a temp 409 | fix and will be replaced with proper pattern matching. 410 | 411 | # 0.0.10 412 | 413 | * Introduce `Context::ContainerChain` to eventually replace the heavy-weight `Skill` object. 414 | * Fix a bug in `Option` where wrong args were passed when used without `flow_options`. 415 | 416 | # 0.0.9 417 | 418 | * Fix `Context#[]`, it returned `nil` when it should return `false`. 419 | 420 | # 0.0.8 421 | 422 | * Make `Trailblazer::Option` and `Trailblazer::Option::KW` a mix of lambda and object so it's easily extendable. 423 | 424 | # 0.0.7 425 | 426 | * It is now `Trailblazer::Args`. 427 | 428 | # 0.0.6 429 | 430 | * `Wrapped` is now `Wrap`. Also, a consistent `Alterations` interface allows tweaking here. 431 | 432 | # 0.0.5 433 | 434 | * The `Wrapped::Runner` now applies `Alterations` to each task's `Circuit`. This means you can inject `:task_alterations` into `Circuit#call`, which will then be merged into the task's original circuit, and then run. While this might sound like crazy talk, this allows any kind of external injection (tracing, input/output contracts, step dependency injections, ...) for specific or all tasks of any circuit. 435 | 436 | # 0.0.4 437 | 438 | * Simpler tracing with `Stack`. 439 | * Added `Context`. 440 | * Simplified `Circuit#call`. 441 | 442 | # 0.0.3 443 | 444 | * Make the first argument to `#Activity` (`@name`) always a Hash where `:id` is a reserved key for the name of the circuit. 445 | 446 | # 0.0.2 447 | 448 | * Make `flow_options` an immutable data structure just as `options`. It now needs to be returned from a `#call`. 449 | 450 | # 0.0.1 451 | 452 | * First release into an unsuspecting world. 🚀 453 | --------------------------------------------------------------------------------