roast-ai 0.4.8 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +1 -0
  4. data/Gemfile +6 -7
  5. data/Gemfile.lock +15 -3
  6. data/README.md +9 -5
  7. data/dsl/demo/Gemfile +4 -0
  8. data/dsl/demo/Gemfile.lock +120 -0
  9. data/dsl/demo/cogs/local.rb +15 -0
  10. data/dsl/demo/simple_external_cog.rb +17 -0
  11. data/dsl/less_simple.rb +112 -0
  12. data/dsl/plugin-gem-example/.gitignore +8 -0
  13. data/dsl/plugin-gem-example/Gemfile +13 -0
  14. data/dsl/plugin-gem-example/Gemfile.lock +178 -0
  15. data/dsl/plugin-gem-example/lib/other.rb +17 -0
  16. data/dsl/plugin-gem-example/lib/plugin_gem_example.rb +5 -0
  17. data/dsl/plugin-gem-example/lib/simple.rb +15 -0
  18. data/dsl/plugin-gem-example/lib/version.rb +10 -0
  19. data/dsl/plugin-gem-example/plugin-gem-example.gemspec +28 -0
  20. data/dsl/prototype.rb +25 -0
  21. data/dsl/scoped_executors.rb +28 -0
  22. data/dsl/simple.rb +5 -7
  23. data/dsl/simple_chat.rb +12 -0
  24. data/dsl/step_communication.rb +24 -0
  25. data/examples/grading/README.md +46 -0
  26. data/examples/grading/analyze_coverage/prompt.md +52 -0
  27. data/examples/grading/calculate_final_grade.rb +64 -0
  28. data/examples/grading/format_result.rb +61 -0
  29. data/examples/grading/generate_grades/prompt.md +105 -0
  30. data/examples/grading/generate_recommendations/output.txt +17 -0
  31. data/examples/grading/generate_recommendations/prompt.md +60 -0
  32. data/examples/grading/read_dependencies/prompt.md +15 -0
  33. data/examples/grading/verify_mocks_and_stubs/prompt.md +12 -0
  34. data/examples/grading/verify_test_helpers/prompt.md +53 -0
  35. data/examples/grading/workflow.md +5 -0
  36. data/examples/grading/workflow.yml +28 -0
  37. data/lib/roast/dsl/cog/config.rb +36 -0
  38. data/lib/roast/dsl/cog/input.rb +30 -0
  39. data/lib/roast/dsl/cog/output.rb +24 -0
  40. data/lib/roast/dsl/cog/registry.rb +39 -0
  41. data/lib/roast/dsl/cog/stack.rb +22 -0
  42. data/lib/roast/dsl/cog/store.rb +29 -0
  43. data/lib/roast/dsl/cog.rb +91 -0
  44. data/lib/roast/dsl/cog_input_context.rb +9 -0
  45. data/lib/roast/dsl/cog_input_manager.rb +47 -0
  46. data/lib/roast/dsl/cogs/chat.rb +78 -0
  47. data/lib/roast/dsl/cogs/cmd.rb +132 -0
  48. data/lib/roast/dsl/cogs/execute.rb +46 -0
  49. data/lib/roast/dsl/cogs/graph.rb +53 -0
  50. data/lib/roast/dsl/config_context.rb +9 -0
  51. data/lib/roast/dsl/config_manager.rb +96 -0
  52. data/lib/roast/dsl/execution_context.rb +9 -0
  53. data/lib/roast/dsl/execution_manager.rb +137 -0
  54. data/lib/roast/dsl/workflow.rb +113 -0
  55. data/lib/roast/error.rb +7 -0
  56. data/lib/roast/errors.rb +3 -3
  57. data/lib/roast/graph/edge.rb +25 -0
  58. data/lib/roast/graph/node.rb +40 -0
  59. data/lib/roast/graph/quantum_edge.rb +27 -0
  60. data/lib/roast/graph/threaded_exec.rb +93 -0
  61. data/lib/roast/graph.rb +233 -0
  62. data/lib/roast/resources/api_resource.rb +2 -2
  63. data/lib/roast/resources/url_resource.rb +2 -2
  64. data/lib/roast/tools/apply_diff.rb +1 -1
  65. data/lib/roast/tools/ask_user.rb +1 -1
  66. data/lib/roast/tools/bash.rb +1 -1
  67. data/lib/roast/tools/cmd.rb +2 -2
  68. data/lib/roast/tools/coding_agent.rb +2 -2
  69. data/lib/roast/tools/grep.rb +1 -1
  70. data/lib/roast/tools/read_file.rb +1 -1
  71. data/lib/roast/tools/search_file.rb +1 -1
  72. data/lib/roast/tools/swarm.rb +1 -1
  73. data/lib/roast/tools/update_files.rb +2 -2
  74. data/lib/roast/tools/write_file.rb +1 -1
  75. data/lib/roast/tools.rb +1 -1
  76. data/lib/roast/value_objects/api_token.rb +1 -1
  77. data/lib/roast/value_objects/uri_base.rb +1 -1
  78. data/lib/roast/value_objects/workflow_path.rb +1 -1
  79. data/lib/roast/version.rb +1 -1
  80. data/lib/roast/workflow/base_workflow.rb +38 -2
  81. data/lib/roast/workflow/command_executor.rb +1 -1
  82. data/lib/roast/workflow/configuration_loader.rb +1 -1
  83. data/lib/roast/workflow/error_handler.rb +1 -1
  84. data/lib/roast/workflow/step_executor_registry.rb +1 -1
  85. data/lib/roast/workflow/step_loader.rb +1 -1
  86. data/lib/roast/workflow/workflow_executor.rb +1 -1
  87. data/lib/roast.rb +4 -3
  88. data/roast.gemspec +1 -0
  89. data/sorbet/config +3 -0
  90. data/sorbet/rbi/annotations/.gitattributes +1 -0
  91. data/sorbet/rbi/annotations/activesupport.rbi +495 -0
  92. data/sorbet/rbi/annotations/faraday.rbi +17 -0
  93. data/sorbet/rbi/annotations/minitest.rbi +119 -0
  94. data/sorbet/rbi/annotations/mocha.rbi +34 -0
  95. data/sorbet/rbi/annotations/rainbow.rbi +269 -0
  96. data/sorbet/rbi/annotations/webmock.rbi +9 -0
  97. data/sorbet/rbi/gems/marcel@1.1.0.rbi +239 -0
  98. data/sorbet/rbi/gems/{rack@2.2.17.rbi → rack@2.2.19.rbi} +55 -38
  99. data/sorbet/rbi/gems/{rexml@3.4.1.rbi → rexml@3.4.2.rbi} +284 -239
  100. data/sorbet/rbi/gems/ruby_llm@1.8.2.rbi +5703 -0
  101. data/sorbet/rbi/shims/lib/roast/dsl/cog_input_context.rbi +17 -0
  102. data/sorbet/rbi/shims/lib/roast/dsl/config_context.rbi +17 -0
  103. data/sorbet/rbi/shims/lib/roast/dsl/execution_context.rbi +17 -0
  104. data/sorbet/rbi/todo.rbi +7 -0
  105. metadata +84 -6
  106. data/lib/roast/dsl/executor.rb +0 -27
  107. data/package-lock.json +0 -6
@@ -0,0 +1,96 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module DSL
6
+ class ConfigManager
7
+ class ConfigManagerError < Roast::Error; end
8
+ class ConfigManagerNotPreparedError < ConfigManagerError; end
9
+ class ConfigManagerAlreadyPreparedError < ConfigManagerError; end
10
+
11
+ #: (Cog::Registry, Array[^() -> void]) -> void
12
+ def initialize(cog_registry, config_procs)
13
+ @cog_registry = cog_registry
14
+ @config_procs = config_procs
15
+ @config_context = ConfigContext.new #: ConfigContext
16
+ @general_configs = {} #: Hash[singleton(Cog), Cog::Config]
17
+ @name_scoped_configs = {} #: Hash[singleton(Cog), Hash[Symbol, Cog::Config]]
18
+ end
19
+
20
+ #: () -> void
21
+ def prepare!
22
+ raise ConfigManagerAlreadyPreparedError if preparing? || prepared?
23
+
24
+ @preparing = true
25
+ bind_registered_cogs
26
+ @config_procs.each { |cp| @config_context.instance_eval(&cp) }
27
+ @prepared = true
28
+ end
29
+
30
+ #: () -> bool
31
+ def preparing?
32
+ @preparing ||= false
33
+ end
34
+
35
+ #: () -> bool
36
+ def prepared?
37
+ @prepared ||= false
38
+ end
39
+
40
+ #: (singleton(Cog), ?Symbol?) -> Cog::Config
41
+ def config_for(cog_class, name = nil)
42
+ raise ConfigManagerNotPreparedError unless prepared?
43
+
44
+ # All cogs will always have a config; empty by default if the cog was never explicitly configured
45
+ config = fetch_general_config(cog_class)
46
+ name_scoped_config = fetch_name_scoped_config(cog_class, name) unless name.nil?
47
+ config = config.merge(name_scoped_config) if name_scoped_config
48
+ config
49
+ end
50
+
51
+ private
52
+
53
+ #: (singleton(Cog)) -> Cog::Config
54
+ def fetch_general_config(cog_class)
55
+ @general_configs[cog_class] ||= cog_class.config_class.new
56
+ end
57
+
58
+ #: (singleton(Cog), Symbol) -> Cog::Config
59
+ def fetch_name_scoped_config(cog_class, name)
60
+ name_scoped_configs_for_cog = @name_scoped_configs[cog_class] ||= {}
61
+ name_scoped_configs_for_cog[name] ||= cog_class.config_class.new
62
+ end
63
+
64
+ #: () -> void
65
+ def bind_registered_cogs
66
+ @cog_registry.cogs.each { |cog_method_name, cog_class| bind_cog(cog_method_name, cog_class) }
67
+ end
68
+
69
+ #: (Symbol, singleton(Cog)) -> void
70
+ def bind_cog(cog_method_name, cog_class)
71
+ on_config_method = method(:on_config)
72
+ cog_method = proc do |cog_name = nil, &cog_config_proc|
73
+ on_config_method.call(cog_class, cog_name, &cog_config_proc)
74
+ end
75
+ @config_context.instance_eval do
76
+ define_singleton_method(cog_method_name, cog_method)
77
+ end
78
+ end
79
+
80
+ #: (singleton(Cog), Symbol) { () -> void } -> void
81
+ def on_config(cog_class, cog_name, &cog_config_proc)
82
+ # Called when the cog method is invoked in the workflow's 'config' block.
83
+ # This allows configuration parameters to be set for the cog generally or for a specific named instance
84
+ config_object = if cog_name.nil?
85
+ fetch_general_config(cog_class)
86
+ else
87
+ fetch_name_scoped_config(cog_class, cog_name)
88
+ end
89
+ # NOTE: Sorbet expects the proc passed to instance_exec to be declared as taking an argument
90
+ # but our cog_config_proc does not get an argument
91
+ config_object.instance_exec(&T.unsafe(cog_config_proc)) if cog_config_proc
92
+ config_object
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,9 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module DSL
6
+ # Context in which the `execute` blocks of a workflow definition are evaluated
7
+ class ExecutionContext; end
8
+ end
9
+ end
@@ -0,0 +1,137 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module DSL
6
+ # Context in which the `execute` block of a workflow is evaluated
7
+ class ExecutionManager
8
+ class ExecutionManagerError < Roast::Error; end
9
+ class ExecutionManagerNotPreparedError < ExecutionManagerError; end
10
+ class ExecutionManagerAlreadyPreparedError < ExecutionManagerError; end
11
+ class ExecutionManagerCurrentlyRunningError < ExecutionManagerError; end
12
+ class ExecutionScopeDoesNotExistError < ExecutionManagerError; end
13
+ class ExecutionScopeNotSpecifiedError < ExecutionManagerError; end
14
+
15
+ #: (Cog::Registry, ConfigManager, Hash[Symbol?, Array[^() -> void]], ?Symbol?) -> void
16
+ def initialize(cog_registry, config_manager, all_execution_procs, scope = nil)
17
+ @cog_registry = cog_registry
18
+ @config_manager = config_manager
19
+ @all_execution_procs = all_execution_procs
20
+ @scope = scope
21
+ @cogs = Cog::Store.new #: Cog::Store
22
+ @cog_stack = Cog::Stack.new #: Cog::Stack
23
+ @execution_context = ExecutionContext.new #: ExecutionContext
24
+ @cog_input_manager = CogInputManager.new(@cog_registry, @cogs) #: CogInputManager
25
+ end
26
+
27
+ #: () -> void
28
+ def prepare!
29
+ raise ExecutionManagerAlreadyPreparedError if preparing? || prepared?
30
+
31
+ @preparing = true
32
+ bind_registered_cogs
33
+ my_execution_procs.each { |ep| @execution_context.instance_eval(&ep) }
34
+ @prepared = true
35
+ end
36
+
37
+ def run!
38
+ raise ExecutionManagerNotPreparedError unless prepared?
39
+ raise ExecutionManagerCurrentlyRunningError if running?
40
+
41
+ @running = true
42
+ @cog_stack.map do |name, cog|
43
+ cog.run!(
44
+ @config_manager.config_for(cog.class, name.to_sym),
45
+ cog_input_manager,
46
+ )
47
+ end
48
+ @running = false
49
+ end
50
+
51
+ #: () -> bool
52
+ def preparing?
53
+ @preparing ||= false
54
+ end
55
+
56
+ #: () -> bool
57
+ def prepared?
58
+ @prepared ||= false
59
+ end
60
+
61
+ #: () -> bool
62
+ def running?
63
+ @running ||= false
64
+ end
65
+
66
+ #: () -> CogInputContext
67
+ def cog_input_manager
68
+ raise ExecutionManagerNotPreparedError unless prepared?
69
+
70
+ @cog_input_manager.context
71
+ end
72
+
73
+ private
74
+
75
+ #: () -> Array[^() -> void]
76
+ def my_execution_procs
77
+ raise ExecutionScopeDoesNotExistError unless @all_execution_procs.key?(@scope)
78
+
79
+ @all_execution_procs[@scope] || []
80
+ end
81
+
82
+ #: (Symbol, Cog) -> void
83
+ def add_cog_instance(name, cog)
84
+ @cogs.insert(name, cog)
85
+ @cog_stack.push([name, cog])
86
+ end
87
+
88
+ # TODO: add typing for output
89
+ #: (Symbol) -> untyped
90
+ def output(name)
91
+ @cogs[name].output
92
+ end
93
+
94
+ #: () -> void
95
+ def bind_registered_cogs
96
+ @cog_registry.cogs.each { |cog_method_name, cog_class| bind_cog(cog_method_name, cog_class) }
97
+ end
98
+
99
+ #: (Symbol, singleton(Cog)) -> void
100
+ def bind_cog(cog_method_name, cog_class)
101
+ on_execute_method = method(:on_execute)
102
+ cog_method = proc do |cog_name = Random.uuid, &cog_input_proc|
103
+ on_execute_method.call(cog_class, cog_name, &cog_input_proc)
104
+ end
105
+ @execution_context.instance_eval do
106
+ define_singleton_method(cog_method_name, cog_method)
107
+ end
108
+ end
109
+
110
+ #: (singleton(Cog), Symbol) { (Cog::Input) -> untyped } -> void
111
+ def on_execute(cog_class, cog_name, &cog_input_proc)
112
+ # Called when the cog method is invoked in the workflow's 'execute' block.
113
+ # This creates the cog instance and prepares it for execution.
114
+ cog_instance = if cog_class == Cogs::Execute
115
+ create_special_execute_cog(cog_name, cog_input_proc)
116
+ else
117
+ cog_class.new(cog_name, cog_input_proc)
118
+ end
119
+ add_cog_instance(cog_name, cog_instance)
120
+ end
121
+
122
+ #: (Symbol, ^(Cogs::Execute::Input) -> untyped) -> Cogs::Execute
123
+ def create_special_execute_cog(cog_name, cog_input_proc)
124
+ trigger = proc do |input|
125
+ raise ExecutionScopeNotSpecifiedError unless input.scope.present?
126
+
127
+ em = ExecutionManager.new(@cog_registry, @config_manager, @all_execution_procs, input.scope)
128
+ em.prepare!
129
+ em.run!
130
+
131
+ # TODO: collect the outputs of the cogs in the execution manager that just ran and do something with them
132
+ end
133
+ Cogs::Execute.new(cog_name, cog_input_proc, trigger)
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,113 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module DSL
6
+ class Workflow
7
+ class WorkflowError < Roast::Error; end
8
+ class WorkflowNotPreparedError < WorkflowError; end
9
+ class WorkflowAlreadyPreparedError < WorkflowError; end
10
+ class WorkflowAlreadyStartedError < WorkflowError; end
11
+ class InvalidCogReference < WorkflowError; end
12
+
13
+ class << self
14
+ #: (String) -> void
15
+ def from_file(workflow_path)
16
+ workflow = new(workflow_path)
17
+ workflow.prepare!
18
+ workflow.start!
19
+ end
20
+ end
21
+
22
+ #: (String) -> void
23
+ def initialize(workflow_path)
24
+ @workflow_path = Pathname.new(workflow_path)
25
+ @workflow_definition = File.read(workflow_path)
26
+ @cog_registry = Cog::Registry.new #: Cog::Registry
27
+ @config_procs = [] #: Array[^() -> void]
28
+ @execution_procs = { nil: [] } #: Hash[Symbol?, Array[^() -> void]]
29
+ @config_manager = nil #: ConfigManager?
30
+ @execution_manager = nil #: ExecutionManager?
31
+ end
32
+
33
+ #: () -> void
34
+ def prepare!
35
+ raise WorkflowAlreadyPreparedError if preparing? || prepared?
36
+
37
+ @preparing = true
38
+ extract_dsl_procs!
39
+ @config_manager = ConfigManager.new(@cog_registry, @config_procs)
40
+ @config_manager.prepare!
41
+ @execution_manager = ExecutionManager.new(@cog_registry, @config_manager, @execution_procs)
42
+ @execution_manager.prepare!
43
+
44
+ @prepared = true
45
+ end
46
+
47
+ #: () -> void
48
+ def start!
49
+ raise WorkflowNotPreparedError unless @config_manager.present? && @execution_manager.present?
50
+ raise WorkflowAlreadyStartedError if started? || completed?
51
+
52
+ @started = true
53
+ @execution_manager.run!
54
+ @completed = true
55
+ end
56
+
57
+ #: () -> bool
58
+ def preparing?
59
+ @preparing ||= false
60
+ end
61
+
62
+ #: () -> bool
63
+ def prepared?
64
+ @prepared ||= false
65
+ end
66
+
67
+ #: () -> bool
68
+ def started?
69
+ @started ||= false
70
+ end
71
+
72
+ #: () -> bool
73
+ def completed?
74
+ @completed ||= false
75
+ end
76
+
77
+ #: { () [self: ConfigContext] -> void } -> void
78
+ def config(&block)
79
+ @config_procs << block
80
+ end
81
+
82
+ #: (?Symbol?) { () [self: ExecutionContext] -> void } -> void
83
+ def execute(scope = nil, &block)
84
+ (@execution_procs[scope] ||= []) << block
85
+ end
86
+
87
+ def use(cogs = [], from: nil)
88
+ require from if from
89
+
90
+ Array.wrap(cogs).each do |cog_name|
91
+ path = @workflow_path.realdirpath.dirname.join("cogs/#{cog_name}")
92
+ require path.to_s if from.nil?
93
+
94
+ cog_class_name = cog_name.camelize
95
+ raise InvalidCogReference, "Expected #{cog_class_name} class, not found in #{path}" unless Object.const_defined?(cog_class_name)
96
+
97
+ cog_class = cog_class_name.constantize # rubocop:disable Sorbet/ConstantsFromStrings
98
+ @cog_registry.use(cog_class)
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ # Evaluate the top-level workflow definition
105
+ # This collects the procs passed to `config` and `execute` calls in the workflow definition,
106
+ # but does not evaluate any of them individually yet.
107
+ #: () -> void
108
+ def extract_dsl_procs!
109
+ instance_eval(@workflow_definition)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,7 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ # Base class for all internal Roast errors.
6
+ class Error < StandardError; end
7
+ end
data/lib/roast/errors.rb CHANGED
@@ -4,12 +4,12 @@
4
4
  module Roast
5
5
  module Errors
6
6
  # Custom error for API resource not found (404) responses
7
- class ResourceNotFoundError < StandardError; end
7
+ class ResourceNotFoundError < Roast::Error; end
8
8
 
9
9
  # Custom error for when API authentication fails
10
- class AuthenticationError < StandardError; end
10
+ class AuthenticationError < Roast::Error; end
11
11
 
12
12
  # Exit the app, for instance via Ctrl-C during an InputStep
13
- class ExitEarly < StandardError; end
13
+ class ExitEarly < Roast::Error; end
14
14
  end
15
15
  end
@@ -0,0 +1,25 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ class Graph
6
+ class Edge
7
+ #: (Node) -> void
8
+ attr_writer :join_node
9
+
10
+ attr_reader :from_node, :to_node
11
+
12
+ #: (Node, Node, ?proc: Proc?) -> void
13
+ def initialize(from_node, to_node, proc: nil)
14
+ @from_node = from_node
15
+ @to_node = to_node
16
+ @proc = proc # TODO: Shadowing proc builtin here
17
+ end
18
+
19
+ #: () -> String
20
+ def to_s
21
+ "#{@from_node} -> #{@to_node}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,40 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ class Graph
6
+ class Node
7
+ # class InvalidExecutableError < Roast::Error; end
8
+
9
+ attr_reader :name, :executable
10
+
11
+ #: (Symbol, ?executable: Proc | Graph | nil) -> void
12
+ def initialize(name, executable: nil)
13
+ @name = name
14
+ @executable = executable
15
+ end
16
+
17
+ #: () -> T::Boolean
18
+ def subgraph?
19
+ @executable.is_a?(Graph)
20
+ end
21
+
22
+ #: () -> T::Boolean
23
+ def done?
24
+ @name == :DONE
25
+ end
26
+
27
+ #: (Hash) -> void
28
+ def execute(state)
29
+ return if @executable.nil?
30
+
31
+ executable = @executable
32
+ if executable.is_a?(Proc)
33
+ executable.call(state)
34
+ elsif executable.is_a?(Graph)
35
+ executable.execute(state)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,27 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ class Graph
6
+ class QuantumEdge
7
+ #: (Roast::Graph::Node, Proc) -> void
8
+ def initialize(from_node, to_proc)
9
+ @from_node = from_node
10
+ @to_proc = to_proc
11
+ end
12
+
13
+ #: (Hash[untyped, untyped], Hash[Symbol, Roast::Graph::Node]) -> Array[Roast::Graph::Edge]
14
+ def collapse(state, nodes)
15
+ to_node_names = @to_proc.call(state)
16
+ to_node_names = to_node_names.is_a?(Array) ? to_node_names : [to_node_names]
17
+
18
+ to_node_names.map do |to_node_name|
19
+ to_node = nodes[to_node_name]
20
+ raise Error, "No node found with name #{to_node_name.inspect}" if to_node.nil?
21
+
22
+ Edge.new(@from_node, to_node)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,93 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ class Graph
6
+ class StateConflictError < Error; end
7
+
8
+ class ThreadedExec
9
+ def initialize(nodes, og_state)
10
+ @nodes = nodes
11
+ @og_state = og_state
12
+ end
13
+
14
+ #: () -> Hash[untyped, untyped]
15
+ def async_execute
16
+ states = threaded_execute(@nodes, @og_state)
17
+ merge_states!(@og_state, states)
18
+ @og_state
19
+ end
20
+
21
+ # Returns a hash of the new states for each node.
22
+ #: (Array[Node], Hash[untyped, untyped]) -> Hash[Symbol, Hash[untyped, untyped]]
23
+ def threaded_execute(nodes, og_state)
24
+ states = {}
25
+ threads = nodes.map do |current_node|
26
+ states[current_node.name] = og_state.dup
27
+ Thread.new do
28
+ current_node.execute(states[current_node.name])
29
+ end
30
+ end
31
+
32
+ threads.map(&:value)
33
+
34
+ states
35
+ end
36
+
37
+ #: (Hash, Hash) -> void
38
+ def merge_states!(orig_state, new_states)
39
+ orig_state.merge!(merge_new_states(orig_state, new_states))
40
+ end
41
+
42
+ # We merge all the new states together first so we can catch any keys that were modified by multiple threads.
43
+ #: (Hash[untyped, untyped], Hash[Symbol, Hash[untyped, untyped]]) -> Hash[untyped, untyped]
44
+ def merge_new_states(orig_state, new_states)
45
+ # We only care about what the threads have changes from the original state.
46
+ changed_states = changed_states(orig_state, new_states)
47
+
48
+ return {} if changed_states.empty?
49
+
50
+ # Grab some entry to be the one we merge all the other changes into.
51
+ base_node_name, base_changed_state = T.must(changed_states.shift)
52
+ base_node_name = T.cast(base_node_name, Symbol)
53
+ base_changed_state = T.cast(base_changed_state, T::Hash[T.untyped, T.untyped])
54
+
55
+ changed_states.each do |node_name, changed_state|
56
+ base_changed_state.merge!(changed_state) do |key, old_value, new_value|
57
+ raise_state_conflict(key, old_value, new_value, [base_node_name, node_name])
58
+ end
59
+ end
60
+
61
+ base_changed_state
62
+ end
63
+
64
+ #: (Hash, Hash[Symbol, Hash]) -> Hash[Symbol, Hash]
65
+ def changed_states(orig_state, new_states)
66
+ new_states.transform_values do |new_state|
67
+ changed_state_entries(orig_state, new_state)
68
+ end
69
+ end
70
+
71
+ # Filter new states content to only include keys that were modified by the new states.
72
+ #: (Hash, Hash) -> Hash
73
+ def changed_state_entries(orig_state, new_state)
74
+ new_state.reject do |key, value|
75
+ orig_state[key] == value
76
+ end
77
+ end
78
+
79
+ #: (Symbol, untyped, untyped, Array[Symbol]) -> void
80
+ def raise_state_conflict(key, old_value, new_value, conflicting_nodes)
81
+ raise StateConflictError, <<~CONFLICT.chomp
82
+ Parallel nodes modified the same state key.
83
+ Conflicting nodes: #{conflicting_nodes.join(", ")}
84
+ Key: :#{key}
85
+ Old value:
86
+ #{old_value.inspect}
87
+ New value:
88
+ #{new_value.inspect}
89
+ CONFLICT
90
+ end
91
+ end
92
+ end
93
+ end