roast-ai 0.4.10 → 0.5.0

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 (173) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/docs/write-comments.md +36 -0
  3. data/.github/CODEOWNERS +1 -1
  4. data/.github/workflows/ci.yaml +10 -6
  5. data/.gitignore +0 -1
  6. data/.rubocop.yml +7 -1
  7. data/CLAUDE.md +2 -2
  8. data/CONTRIBUTING.md +2 -0
  9. data/Gemfile +19 -18
  10. data/Gemfile.lock +35 -58
  11. data/README.md +118 -1432
  12. data/README_LEGACY.md +1464 -0
  13. data/Rakefile +39 -4
  14. data/dev.yml +29 -0
  15. data/dsl/agent_sessions.rb +20 -0
  16. data/dsl/async_cogs.rb +49 -0
  17. data/dsl/async_cogs_complex.rb +67 -0
  18. data/dsl/call.rb +44 -0
  19. data/dsl/collect_from.rb +72 -0
  20. data/dsl/json_output.rb +28 -0
  21. data/dsl/map.rb +55 -0
  22. data/dsl/map_reduce.rb +37 -0
  23. data/dsl/map_with_index.rb +49 -0
  24. data/dsl/next_break.rb +40 -0
  25. data/dsl/next_break_parallel.rb +44 -0
  26. data/dsl/outputs.rb +39 -0
  27. data/dsl/outputs_bang.rb +36 -0
  28. data/dsl/parallel_map.rb +37 -0
  29. data/dsl/prompts/simple_prompt.md.erb +3 -0
  30. data/dsl/prototype.rb +5 -7
  31. data/dsl/repeat_loop_results.rb +53 -0
  32. data/dsl/ruby_cog.rb +72 -0
  33. data/dsl/simple_agent.rb +18 -0
  34. data/dsl/simple_chat.rb +15 -1
  35. data/dsl/simple_repeat.rb +29 -0
  36. data/dsl/skip.rb +36 -0
  37. data/dsl/step_communication.rb +2 -3
  38. data/dsl/targets_and_params.rb +57 -0
  39. data/dsl/temperature.rb +17 -0
  40. data/dsl/temporary_directory.rb +22 -0
  41. data/dsl/tutorial/01_your_first_workflow/README.md +179 -0
  42. data/dsl/tutorial/01_your_first_workflow/configured_chat.rb +33 -0
  43. data/dsl/tutorial/01_your_first_workflow/hello.rb +23 -0
  44. data/dsl/tutorial/02_chaining_cogs/README.md +310 -0
  45. data/dsl/tutorial/02_chaining_cogs/code_review.rb +104 -0
  46. data/dsl/tutorial/02_chaining_cogs/session_resumption.rb +92 -0
  47. data/dsl/tutorial/02_chaining_cogs/simple_chain.rb +84 -0
  48. data/dsl/tutorial/03_targets_and_params/README.md +230 -0
  49. data/dsl/tutorial/03_targets_and_params/multiple_targets.rb +65 -0
  50. data/dsl/tutorial/03_targets_and_params/single_target.rb +65 -0
  51. data/dsl/tutorial/04_configuration_options/README.md +209 -0
  52. data/dsl/tutorial/04_configuration_options/control_display_and_temperature.rb +104 -0
  53. data/dsl/tutorial/04_configuration_options/simple_config.rb +68 -0
  54. data/dsl/tutorial/05_control_flow/README.md +156 -0
  55. data/dsl/tutorial/05_control_flow/conditional_execution.rb +62 -0
  56. data/dsl/tutorial/05_control_flow/handling_failures.rb +77 -0
  57. data/dsl/tutorial/06_reusable_scopes/README.md +172 -0
  58. data/dsl/tutorial/06_reusable_scopes/accessing_scope_outputs.rb +126 -0
  59. data/dsl/tutorial/06_reusable_scopes/basic_scope.rb +63 -0
  60. data/dsl/tutorial/06_reusable_scopes/parameterized_scope.rb +78 -0
  61. data/dsl/tutorial/07_processing_collections/README.md +152 -0
  62. data/dsl/tutorial/07_processing_collections/basic_map.rb +70 -0
  63. data/dsl/tutorial/07_processing_collections/parallel_map.rb +74 -0
  64. data/dsl/tutorial/08_iterative_workflows/README.md +231 -0
  65. data/dsl/tutorial/08_iterative_workflows/basic_repeat.rb +57 -0
  66. data/dsl/tutorial/08_iterative_workflows/conditional_break.rb +57 -0
  67. data/dsl/tutorial/09_async_cogs/README.md +197 -0
  68. data/dsl/tutorial/09_async_cogs/basic_async.rb +38 -0
  69. data/dsl/tutorial/README.md +222 -0
  70. data/dsl/working_directory.rb +16 -0
  71. data/exe/roast +1 -1
  72. data/internal/documentation/architectural-notes.md +115 -0
  73. data/internal/documentation/doc-comments-external.md +686 -0
  74. data/internal/documentation/doc-comments-internal.md +342 -0
  75. data/internal/documentation/doc-comments.md +211 -0
  76. data/lib/roast/dsl/cog/config.rb +274 -3
  77. data/lib/roast/dsl/cog/input.rb +53 -10
  78. data/lib/roast/dsl/cog/output.rb +297 -8
  79. data/lib/roast/dsl/cog/registry.rb +35 -3
  80. data/lib/roast/dsl/cog/stack.rb +1 -1
  81. data/lib/roast/dsl/cog/store.rb +5 -5
  82. data/lib/roast/dsl/cog.rb +70 -14
  83. data/lib/roast/dsl/cog_input_context.rb +36 -1
  84. data/lib/roast/dsl/cog_input_manager.rb +116 -7
  85. data/lib/roast/dsl/cogs/agent/config.rb +465 -0
  86. data/lib/roast/dsl/cogs/agent/input.rb +81 -0
  87. data/lib/roast/dsl/cogs/agent/output.rb +59 -0
  88. data/lib/roast/dsl/cogs/agent/provider.rb +51 -0
  89. data/lib/roast/dsl/cogs/agent/providers/claude/claude_invocation.rb +185 -0
  90. data/lib/roast/dsl/cogs/agent/providers/claude/message.rb +73 -0
  91. data/lib/roast/dsl/cogs/agent/providers/claude/messages/assistant_message.rb +36 -0
  92. data/lib/roast/dsl/cogs/agent/providers/claude/messages/result_message.rb +61 -0
  93. data/lib/roast/dsl/cogs/agent/providers/claude/messages/system_message.rb +47 -0
  94. data/lib/roast/dsl/cogs/agent/providers/claude/messages/text_message.rb +36 -0
  95. data/lib/roast/dsl/cogs/agent/providers/claude/messages/tool_result_message.rb +47 -0
  96. data/lib/roast/dsl/cogs/agent/providers/claude/messages/tool_use_message.rb +46 -0
  97. data/lib/roast/dsl/cogs/agent/providers/claude/messages/unknown_message.rb +27 -0
  98. data/lib/roast/dsl/cogs/agent/providers/claude/messages/user_message.rb +37 -0
  99. data/lib/roast/dsl/cogs/agent/providers/claude/tool_result.rb +51 -0
  100. data/lib/roast/dsl/cogs/agent/providers/claude/tool_use.rb +48 -0
  101. data/lib/roast/dsl/cogs/agent/providers/claude.rb +31 -0
  102. data/lib/roast/dsl/cogs/agent/stats.rb +92 -0
  103. data/lib/roast/dsl/cogs/agent/usage.rb +62 -0
  104. data/lib/roast/dsl/cogs/agent.rb +75 -0
  105. data/lib/roast/dsl/cogs/chat/config.rb +453 -0
  106. data/lib/roast/dsl/cogs/chat/input.rb +92 -0
  107. data/lib/roast/dsl/cogs/chat/output.rb +64 -0
  108. data/lib/roast/dsl/cogs/chat/session.rb +68 -0
  109. data/lib/roast/dsl/cogs/chat.rb +59 -56
  110. data/lib/roast/dsl/cogs/cmd.rb +248 -61
  111. data/lib/roast/dsl/cogs/ruby.rb +171 -0
  112. data/lib/roast/dsl/command_runner.rb +191 -0
  113. data/lib/roast/dsl/config_manager.rb +58 -11
  114. data/lib/roast/dsl/control_flow.rb +41 -0
  115. data/lib/roast/dsl/execution_manager.rb +162 -32
  116. data/lib/roast/dsl/nil_assertions.rb +23 -0
  117. data/lib/roast/dsl/system_cog/params.rb +32 -0
  118. data/lib/roast/dsl/system_cog.rb +36 -0
  119. data/lib/roast/dsl/system_cogs/call.rb +162 -0
  120. data/lib/roast/dsl/system_cogs/map.rb +448 -0
  121. data/lib/roast/dsl/system_cogs/repeat.rb +242 -0
  122. data/lib/roast/dsl/workflow.rb +26 -16
  123. data/lib/roast/dsl/workflow_context.rb +20 -0
  124. data/lib/roast/dsl/workflow_params.rb +24 -0
  125. data/lib/roast/sorbet_runtime_stub.rb +154 -0
  126. data/lib/roast/tools/apply_diff.rb +1 -3
  127. data/lib/roast/tools/cmd.rb +4 -3
  128. data/lib/roast/tools/read_file.rb +1 -1
  129. data/lib/roast/tools/update_files.rb +1 -1
  130. data/lib/roast/tools/write_file.rb +1 -1
  131. data/lib/roast/version.rb +1 -1
  132. data/lib/roast/workflow/base_workflow.rb +4 -0
  133. data/lib/roast/workflow/step_loader.rb +14 -2
  134. data/lib/roast-ai.rb +4 -0
  135. data/lib/roast.rb +58 -21
  136. data/{roast.gemspec → roast-ai.gemspec} +9 -13
  137. data/sorbet/rbi/gems/async@2.34.0.rbi +1577 -0
  138. data/sorbet/rbi/gems/cli-kit@5.2.0.rbi +2063 -0
  139. data/sorbet/rbi/gems/{cli-ui@2.3.0.rbi → cli-ui@2.7.0-6bdefd1d06305e5d6ae312ac76f9c88f88658dda.rbi} +1418 -1013
  140. data/sorbet/rbi/gems/console@1.34.2.rbi +1193 -0
  141. data/sorbet/rbi/gems/fiber-annotation@0.2.0.rbi +50 -0
  142. data/sorbet/rbi/gems/fiber-local@1.1.0.rbi +35 -0
  143. data/sorbet/rbi/gems/fiber-storage@1.0.1.rbi +41 -0
  144. data/sorbet/rbi/gems/io-event@1.14.0.rbi +724 -0
  145. data/sorbet/rbi/gems/metrics@0.15.0.rbi +9 -0
  146. data/sorbet/rbi/gems/traces@0.18.2.rbi +9 -0
  147. data/sorbet/rbi/shims/lib/roast/dsl/cog_input_context.rbi +1185 -5
  148. data/sorbet/rbi/shims/lib/roast/dsl/config_context.rbi +311 -5
  149. data/sorbet/rbi/shims/lib/roast/dsl/execution_context.rbi +486 -5
  150. data/sorbet/tapioca/config.yml +6 -0
  151. data/sorbet/tapioca/require.rb +2 -0
  152. metadata +157 -30
  153. data/dsl/less_simple.rb +0 -112
  154. data/dsl/scoped_executors.rb +0 -28
  155. data/dsl/simple.rb +0 -8
  156. data/lib/roast/dsl/cogs/execute.rb +0 -46
  157. data/lib/roast/dsl/cogs/graph.rb +0 -53
  158. data/sorbet/rbi/gems/cgi@0.5.0.rbi +0 -2961
  159. data/sorbet/rbi/gems/claude_swarm@0.1.19.rbi +0 -568
  160. data/sorbet/rbi/gems/cli-kit@5.0.1.rbi +0 -1991
  161. data/sorbet/rbi/gems/dry-configurable@1.3.0.rbi +0 -672
  162. data/sorbet/rbi/gems/dry-core@1.1.0.rbi +0 -1894
  163. data/sorbet/rbi/gems/dry-inflector@1.2.0.rbi +0 -659
  164. data/sorbet/rbi/gems/dry-initializer@3.2.0.rbi +0 -781
  165. data/sorbet/rbi/gems/dry-logic@1.6.0.rbi +0 -1127
  166. data/sorbet/rbi/gems/dry-schema@1.14.1.rbi +0 -3727
  167. data/sorbet/rbi/gems/dry-types@1.8.3.rbi +0 -3969
  168. data/sorbet/rbi/gems/fast-mcp-annotations@1.5.3.rbi +0 -1588
  169. data/sorbet/rbi/gems/mime-types-data@3.2025.0617.rbi +0 -136
  170. data/sorbet/rbi/gems/mime-types@3.7.0.rbi +0 -1342
  171. data/sorbet/rbi/gems/rack@2.2.19.rbi +0 -5676
  172. data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +0 -435
  173. data/sorbet/rbi/gems/yard@0.9.37.rbi +0 -18492
@@ -0,0 +1,191 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module DSL
6
+ # The canonical way to execute shell commands in Roast.
7
+ #
8
+ # CommandRunner is the standard command execution interface for DSL cogs
9
+ # and should be used for all command invocations in this project.
10
+ #
11
+ # Features:
12
+ # - Separate stdout/stderr capture (using Async fibers for concurrency)
13
+ # - Line-by-line streaming callbacks for custom handling
14
+ # - Optional timeout support with automatic process cleanup
15
+ # - Direct command execution (no shell by default for safety)
16
+ #
17
+ # Note: Currently executes commands directly without shell features.
18
+ # Shell support (pipes, redirects, etc.) will be added in a future version.
19
+ class CommandRunner
20
+ class CommandRunnerError < StandardError; end
21
+
22
+ class NoCommandProvidedError < CommandRunnerError; end
23
+
24
+ class TimeoutError < CommandRunnerError; end
25
+
26
+ class << self
27
+ # Execute a command with optional stream handlers
28
+ #
29
+ # @param args [Array<String>] Command and arguments as an array
30
+ # @param timeout [Integer, nil] Timeout in seconds (default: nil, no timeout)
31
+ # @param stdout_handler [Proc, nil] Called for each stdout line
32
+ # @param stderr_handler [Proc, nil] Called for each stderr line
33
+ # @return [Array<String, String, Process::Status>] stdout, stderr, status
34
+ #
35
+ # @example Basic usage
36
+ # stdout, stderr, status = CommandRunner.execute(["echo", "hello"])
37
+ #
38
+ # @example With handlers for streaming output
39
+ # CommandRunner.execute(
40
+ # ["ls", "-la"],
41
+ # stdout_handler: ->(line) { puts "[OUT] #{line}" }
42
+ # )
43
+ #
44
+ # @example With explicit timeout
45
+ # CommandRunner.execute(["sleep", "5"], timeout: 2) # Will timeout after 2 seconds
46
+ #: (
47
+ #| Array[String],
48
+ #| ?working_directory: (Pathname | String)?,
49
+ #| ?timeout: (Integer | Float)?,
50
+ #| ?stdin_content: String?,
51
+ #| ?stdout_handler: (^(String) -> void)?,
52
+ #| ?stderr_handler: (^(String) -> void)?,
53
+ #| ) -> [String, String, Process::Status]
54
+ def execute(
55
+ args,
56
+ working_directory: nil,
57
+ timeout: nil,
58
+ stdin_content: nil,
59
+ stdout_handler: nil,
60
+ stderr_handler: nil
61
+ )
62
+ args.compact!
63
+ raise NoCommandProvidedError if args.blank?
64
+
65
+ stdin, stdout, stderr, wait_thread = Open3 #: as untyped
66
+ .popen3(
67
+ { "PWD" => working_directory&.to_s }.compact,
68
+ *args,
69
+ { chdir: working_directory }.compact,
70
+ )
71
+ stdin.puts stdin_content if stdin_content.present?
72
+ stdin.close
73
+ pid = wait_thread.pid
74
+
75
+ # If timeout is specified, start a timer in a separate fiber
76
+ timeout_task = if timeout
77
+ Async do |task|
78
+ task.annotate("CommandRunner Timeout Monitor")
79
+ sleep(timeout)
80
+ kill_process(pid) if pid
81
+ end
82
+ end
83
+
84
+ # Read stdout and stderr concurrently
85
+ stdout_content, stderr_content = Sync do |sync_task|
86
+ sync_task.annotate("CommandRunner Process Handler")
87
+ stdout_task = Async do |task|
88
+ task.annotate("CommandRunner Standard Output Reader")
89
+ buffer = "" #: String
90
+ stdout.each_line do |line|
91
+ buffer += line
92
+ begin
93
+ stdout_handler&.call(line)
94
+ rescue => e
95
+ Roast::Helpers::Logger.debug("stdout_handler raised: #{e.class} - #{e.message}")
96
+ end
97
+ end
98
+ buffer
99
+ rescue IOError
100
+ buffer
101
+ end
102
+
103
+ stderr_task = Async do |task|
104
+ task.annotate("CommandRunner Standard Error Reader")
105
+ buffer = "" #: String
106
+ stderr.each_line do |line|
107
+ buffer += line
108
+ begin
109
+ stderr_handler&.call(line)
110
+ rescue => e
111
+ Roast::Helpers::Logger.debug("stderr_handler raised: #{e.class} - #{e.message}")
112
+ end
113
+ end
114
+ buffer
115
+ rescue IOError
116
+ buffer
117
+ end
118
+
119
+ [stdout_task.wait, stderr_task.wait]
120
+ end
121
+
122
+ # Wait for the process to complete
123
+ status = wait_thread.value
124
+
125
+ # Cancel the timeout task if it's still running
126
+ timeout_task&.stop
127
+
128
+ # Check if the process was killed due to timeout
129
+ if timeout && status.signaled? && (status.termsig == 15 || status.termsig == 9)
130
+ raise TimeoutError, "Command timed out after #{timeout} seconds"
131
+ end
132
+
133
+ [stdout_content, stderr_content, status]
134
+ ensure
135
+ # Clean up resources
136
+ begin
137
+ [stdout, stderr].compact.each(&:close)
138
+ rescue
139
+ nil
140
+ end
141
+ # If we haven't waited for the process yet, kill it
142
+ if pid && wait_thread&.alive?
143
+ Async do |task|
144
+ task.annotate("CommandRunner Process Killer")
145
+ kill_process(pid)
146
+ end.wait
147
+ wait_thread.join(1) # Give it a second to finish
148
+ end
149
+ end
150
+
151
+ private
152
+
153
+ #: (Integer) -> void
154
+ def kill_process(pid)
155
+ return unless process_running?(pid)
156
+
157
+ # First try TERM signal
158
+ Process.kill("TERM", pid)
159
+
160
+ # Give process a short time to terminate gracefully
161
+ 5.times do
162
+ sleep(0.02)
163
+ return unless process_running?(pid)
164
+ end
165
+
166
+ # If still running, use KILL signal
167
+ Process.kill("KILL", pid) if process_running?(pid)
168
+
169
+ # Also try to kill the process group to ensure child processes are killed
170
+ begin
171
+ Process.kill("-KILL", pid)
172
+ rescue
173
+ nil
174
+ end
175
+ rescue Errno::ESRCH
176
+ # Process already terminated
177
+ rescue Errno::EPERM
178
+ Roast::Helpers::Logger.debug("Could not kill process #{pid}: Permission denied")
179
+ end
180
+
181
+ #: (Integer) -> bool
182
+ def process_running?(pid)
183
+ Process.getpgid(pid)
184
+ true
185
+ rescue Errno::ESRCH
186
+ false
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -7,13 +7,16 @@ module Roast
7
7
  class ConfigManagerError < Roast::Error; end
8
8
  class ConfigManagerNotPreparedError < ConfigManagerError; end
9
9
  class ConfigManagerAlreadyPreparedError < ConfigManagerError; end
10
+ class IllegalCogNameError < ConfigManagerError; end
10
11
 
11
12
  #: (Cog::Registry, Array[^() -> void]) -> void
12
13
  def initialize(cog_registry, config_procs)
13
14
  @cog_registry = cog_registry
14
15
  @config_procs = config_procs
15
16
  @config_context = ConfigContext.new #: ConfigContext
17
+ @global_config = Cog::Config.new #: Cog::Config
16
18
  @general_configs = {} #: Hash[singleton(Cog), Cog::Config]
19
+ @regexp_scoped_configs = {} #: Hash[singleton(Cog), Hash[Regexp, Cog::Config]]
17
20
  @name_scoped_configs = {} #: Hash[singleton(Cog), Hash[Symbol, Cog::Config]]
18
21
  end
19
22
 
@@ -22,6 +25,7 @@ module Roast
22
25
  raise ConfigManagerAlreadyPreparedError if preparing? || prepared?
23
26
 
24
27
  @preparing = true
28
+ bind_global
25
29
  bind_registered_cogs
26
30
  @config_procs.each { |cp| @config_context.instance_eval(&cp) }
27
31
  @prepared = true
@@ -42,9 +46,16 @@ module Roast
42
46
  raise ConfigManagerNotPreparedError unless prepared?
43
47
 
44
48
  # 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
49
+ config = cog_class.config_class.new(@global_config.instance_variable_get(:@values).deep_dup)
50
+ config = config.merge(fetch_general_config(cog_class))
51
+ @regexp_scoped_configs.fetch(cog_class, {}).select do |pattern, _|
52
+ pattern.match?(name.to_s) unless name.nil?
53
+ end.values.each { |cfg| config = config.merge(cfg) }
54
+ unless name.nil?
55
+ name_scoped_config = fetch_name_scoped_config(cog_class, name)
56
+ config = config.merge(name_scoped_config)
57
+ end
58
+ config.validate!
48
59
  config
49
60
  end
50
61
 
@@ -55,6 +66,12 @@ module Roast
55
66
  @general_configs[cog_class] ||= cog_class.config_class.new
56
67
  end
57
68
 
69
+ #: (singleton(Cog), Regexp) -> Cog::Config
70
+ def fetch_regexp_scoped_config(cog_class, pattern)
71
+ regexp_scoped_configs_for_cog = @regexp_scoped_configs[cog_class] ||= {}
72
+ regexp_scoped_configs_for_cog[pattern] ||= cog_class.config_class.new
73
+ end
74
+
58
75
  #: (singleton(Cog), Symbol) -> Cog::Config
59
76
  def fetch_name_scoped_config(cog_class, name)
60
77
  name_scoped_configs_for_cog = @name_scoped_configs[cog_class] ||= {}
@@ -69,27 +86,57 @@ module Roast
69
86
  #: (Symbol, singleton(Cog)) -> void
70
87
  def bind_cog(cog_method_name, cog_class)
71
88
  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)
89
+ cog_method = proc do |cog_name_or_pattern = nil, &cog_config_proc|
90
+ on_config_method.call(cog_class, cog_name_or_pattern, cog_config_proc)
74
91
  end
75
92
  @config_context.instance_eval do
93
+ raise IllegalCogNameError, cog_method_name if respond_to?(cog_method_name, true)
94
+
76
95
  define_singleton_method(cog_method_name, cog_method)
77
96
  end
78
97
  end
79
98
 
80
- #: (singleton(Cog), Symbol) { () -> void } -> void
81
- def on_config(cog_class, cog_name, &cog_config_proc)
99
+ #: (singleton(Cog), (Symbol | Regexp)?, ^() -> void ) -> void
100
+ def on_config(cog_class, cog_name_or_pattern, cog_config_proc)
82
101
  # Called when the cog method is invoked in the workflow's 'config' block.
83
102
  # This allows configuration parameters to be set for the cog generally or for a specific named instance
84
- config_object = if cog_name.nil?
103
+
104
+ # NOTE: cast to untyped is to intentional handling the 'unreachable' else case here.
105
+ # This method takes user input directly so additional validation with a clearer exception message will be helpful
106
+ cog_name_or_pattern = cog_name_or_pattern #: untyped
107
+ config_object = case cog_name_or_pattern
108
+ when NilClass
85
109
  fetch_general_config(cog_class)
110
+ when Regexp
111
+ fetch_regexp_scoped_config(cog_class, cog_name_or_pattern)
112
+ when Symbol
113
+ fetch_name_scoped_config(cog_class, cog_name_or_pattern)
86
114
  else
87
- fetch_name_scoped_config(cog_class, cog_name)
115
+ raise ArgumentError, "Invalid type '#{cog_name_or_pattern.class}' for cog_name_or_pattern"
88
116
  end
117
+
89
118
  # NOTE: Sorbet expects the proc passed to instance_exec to be declared as taking an argument
90
119
  # 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
120
+ cog_config_proc = cog_config_proc #: as ^(untyped) -> void
121
+ config_object.instance_exec(&cog_config_proc) if cog_config_proc
122
+ nil
123
+ end
124
+
125
+ def bind_global
126
+ on_global_method = method(:on_global)
127
+ method_to_bind = proc do |&global_proc|
128
+ on_global_method.call(global_proc)
129
+ end
130
+ @config_context.instance_eval do
131
+ define_singleton_method(:global, method_to_bind)
132
+ end
133
+ end
134
+
135
+ #: (^() -> void ) -> void
136
+ def on_global(global_config_proc)
137
+ global_config_proc = global_config_proc #: as ^(untyped) -> void
138
+ @global_config.instance_exec(&global_config_proc) if global_config_proc
139
+ nil
93
140
  end
94
141
  end
95
142
  end
@@ -0,0 +1,41 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module DSL
6
+ # Errors that can be raised to control workflow execution
7
+ module ControlFlow
8
+ class Base < StandardError; end
9
+
10
+ # Raised in a cog's input block or execute method to terminate the cog and mark it as 'skipped'
11
+ # without triggering any failure handling
12
+ class SkipCog < Base; end
13
+
14
+ # Raised in a cog's input block or execute method to terminate the cog and mark it as 'failed'
15
+ # without terminating the workflow. The workflow may abort anyway if the cog is configured to abort the
16
+ # workflow on failure.
17
+ class FailCog < Base; end
18
+
19
+ # Raised in a cog's input block or execute method to terminate the current loop iteration
20
+ # and start the next iteration immediately. The current cog will be marked as 'skipped',
21
+ # and every subsequent cog in the current iteration will not run. Any async cogs currently running in the
22
+ # current scope will be stopped.
23
+ #
24
+ # If this exception is raised outside of a loop (e.g, via the `call` cog, or in the top-level executor),
25
+ # this exception will just terminate that executor as described above without starting a 'next' iteration.
26
+ class Next < Base; end
27
+
28
+ # Raised in a cog's input block or execute method to terminate the current loop iteration immediately
29
+ # and skip all subsequent loop iterations. The current cog will be marked as 'skipped',
30
+ # and every subsequent cog in the current iteration will not run. Any async cogs currently running in the
31
+ # current scope will be stopped.
32
+ #
33
+ # If this exception is raised outside of a loop (e.g, via the `call` cog, or in the top-level executor),
34
+ # this exception will just terminate that executor as described above without starting a 'next' iteration.
35
+ #
36
+ # If this exception is raised inside a `map`, this exception will prevent any subsequent executor invocations
37
+ # within that map and will stop any async invocations running in parallel.
38
+ class Break < Base; end
39
+ end
40
+ end
41
+ end
@@ -5,23 +5,61 @@ module Roast
5
5
  module DSL
6
6
  # Context in which the `execute` block of a workflow is evaluated
7
7
  class ExecutionManager
8
+ include SystemCogs::Call::Manager
9
+ include SystemCogs::Map::Manager
10
+ include SystemCogs::Repeat::Manager
11
+
8
12
  class ExecutionManagerError < Roast::Error; end
13
+
9
14
  class ExecutionManagerNotPreparedError < ExecutionManagerError; end
15
+
10
16
  class ExecutionManagerAlreadyPreparedError < ExecutionManagerError; end
17
+
11
18
  class ExecutionManagerCurrentlyRunningError < ExecutionManagerError; end
19
+
12
20
  class ExecutionScopeDoesNotExistError < ExecutionManagerError; end
21
+
13
22
  class ExecutionScopeNotSpecifiedError < ExecutionManagerError; end
14
23
 
15
- #: (Cog::Registry, ConfigManager, Hash[Symbol?, Array[^() -> void]], ?Symbol?) -> void
16
- def initialize(cog_registry, config_manager, all_execution_procs, scope = nil)
24
+ class IllegalCogNameError < ExecutionManagerError; end
25
+
26
+ class OutputsAlreadyDefinedError < ExecutionManagerError; end
27
+
28
+ #: untyped
29
+ attr_reader :final_output
30
+
31
+ #: (
32
+ #| Cog::Registry,
33
+ #| ConfigManager,
34
+ #| Hash[Symbol?, Array[^() -> void]],
35
+ #| WorkflowContext,
36
+ #| ?scope: Symbol?,
37
+ #| ?scope_value: untyped?,
38
+ #| ?scope_index: Integer
39
+ #| ) -> void
40
+ def initialize(
41
+ cog_registry,
42
+ config_manager,
43
+ all_execution_procs,
44
+ workflow_context,
45
+ scope: nil,
46
+ scope_value: nil,
47
+ scope_index: 0
48
+ )
17
49
  @cog_registry = cog_registry
18
50
  @config_manager = config_manager
19
51
  @all_execution_procs = all_execution_procs
52
+ @workflow_context = workflow_context
20
53
  @scope = scope
54
+ @scope_value = scope_value
55
+ @scope_index = scope_index
21
56
  @cogs = Cog::Store.new #: Cog::Store
22
57
  @cog_stack = Cog::Stack.new #: Cog::Stack
23
58
  @execution_context = ExecutionContext.new #: ExecutionContext
24
- @cog_input_manager = CogInputManager.new(@cog_registry, @cogs) #: CogInputManager
59
+ @cog_input_manager = CogInputManager.new(@cog_registry, @cogs, @workflow_context) #: CogInputManager
60
+ @barrier = Async::Barrier.new #: Async::Barrier
61
+ @final_output = nil #: untyped
62
+ @final_output_computed = false #: bool
25
63
  end
26
64
 
27
65
  #: () -> void
@@ -29,6 +67,7 @@ module Roast
29
67
  raise ExecutionManagerAlreadyPreparedError if preparing? || prepared?
30
68
 
31
69
  @preparing = true
70
+ bind_outputs
32
71
  bind_registered_cogs
33
72
  my_execution_procs.each { |ep| @execution_context.instance_eval(&ep) }
34
73
  @prepared = true
@@ -39,13 +78,33 @@ module Roast
39
78
  raise ExecutionManagerCurrentlyRunningError if running?
40
79
 
41
80
  @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
- )
81
+ Sync do |sync_task|
82
+ sync_task.annotate("ExecutionManager #{@scope}")
83
+ @cog_stack.each do |cog|
84
+ cog_config = @config_manager.config_for(cog.class, cog.name)
85
+ cog_task = cog.run!(
86
+ @barrier,
87
+ cog_config.deep_dup,
88
+ cog_input_context,
89
+ @scope_value.deep_dup,
90
+ @scope_index,
91
+ )
92
+ cog_task.wait unless cog_config.async?
93
+ end
94
+ # Wait on the tasks in their completion order, so that an exception in a task will be raised as soon as it occurs
95
+ # noinspection RubyArgCount
96
+ @barrier.wait { |task| wait_for_task_with_exception_handling(task) }
97
+ compute_final_output # eagerly compute the final output (so it, too, can 'break!' subsequent executions in a loop)
98
+ ensure
99
+ @barrier.stop
100
+ compute_final_output
101
+ @running = false
47
102
  end
48
- @running = false
103
+ end
104
+
105
+ #: () -> void
106
+ def stop!
107
+ @barrier.stop
49
108
  end
50
109
 
51
110
  #: () -> bool
@@ -64,7 +123,7 @@ module Roast
64
123
  end
65
124
 
66
125
  #: () -> CogInputContext
67
- def cog_input_manager
126
+ def cog_input_context
68
127
  raise ExecutionManagerNotPreparedError unless prepared?
69
128
 
70
129
  @cog_input_manager.context
@@ -72,17 +131,32 @@ module Roast
72
131
 
73
132
  private
74
133
 
134
+ #: (Async::Task) -> void
135
+ def wait_for_task_with_exception_handling(task)
136
+ task.wait
137
+ rescue ControlFlow::Next
138
+ # TODO: do something with the message passed to next!
139
+ @barrier.stop
140
+ rescue ControlFlow::Break => e
141
+ @barrier.stop
142
+ compute_final_output # make sure the final output is always computed, even if the iteration is broken
143
+ raise e
144
+ rescue StandardError => e
145
+ @barrier.stop
146
+ raise e
147
+ end
148
+
75
149
  #: () -> Array[^() -> void]
76
150
  def my_execution_procs
77
- raise ExecutionScopeDoesNotExistError unless @all_execution_procs.key?(@scope)
151
+ raise ExecutionScopeDoesNotExistError, @scope unless @all_execution_procs.key?(@scope)
78
152
 
79
153
  @all_execution_procs[@scope] || []
80
154
  end
81
155
 
82
- #: (Symbol, Cog) -> void
83
- def add_cog_instance(name, cog)
84
- @cogs.insert(name, cog)
85
- @cog_stack.push([name, cog])
156
+ #: (Cog) -> void
157
+ def add_cog_instance(cog)
158
+ @cogs.insert(cog)
159
+ @cog_stack.push(cog)
86
160
  end
87
161
 
88
162
  # TODO: add typing for output
@@ -99,38 +173,94 @@ module Roast
99
173
  #: (Symbol, singleton(Cog)) -> void
100
174
  def bind_cog(cog_method_name, cog_class)
101
175
  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)
176
+ cog_method = proc do |*args, **kwargs, &cog_input_proc|
177
+ on_execute_method.call(cog_class, args, kwargs, cog_input_proc)
104
178
  end
105
179
  @execution_context.instance_eval do
180
+ raise IllegalCogNameError, cog_method_name if respond_to?(cog_method_name, true)
181
+
106
182
  define_singleton_method(cog_method_name, cog_method)
107
183
  end
108
184
  end
109
185
 
110
- #: (singleton(Cog), Symbol) { (Cog::Input) -> untyped } -> void
111
- def on_execute(cog_class, cog_name, &cog_input_proc)
186
+ #: (singleton(Cog), Array[untyped], Hash[Symbol, untyped], ^(Cog::Input) -> untyped) -> void
187
+ def on_execute(cog_class, cog_args, cog_kwargs, cog_input_proc)
112
188
  # Called when the cog method is invoked in the workflow's 'execute' block.
113
189
  # 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)
190
+ if cog_class <= SystemCog
191
+ untyped_cog_class = cog_class #: as untyped // to remove warning about splats of unknown length
192
+ cog_params = untyped_cog_class.params_class.new(*cog_args, **cog_kwargs)
193
+ cog_instance = if cog_class == SystemCogs::Call
194
+ create_call_system_cog(cog_params, cog_input_proc)
195
+ elsif cog_class == SystemCogs::Map
196
+ create_map_system_cog(cog_params, cog_input_proc)
197
+ elsif cog_class == SystemCogs::Repeat
198
+ create_repeat_system_cog(cog_params, cog_input_proc)
199
+ else
200
+ raise NotImplementedError, "No system cog manager defined for #{cog_class}"
201
+ end
116
202
  else
117
- cog_class.new(cog_name, cog_input_proc)
203
+ cog_name = Array.wrap(cog_args).shift || Cog.generate_fallback_name
204
+ cog_instance = cog_class.new(cog_name, cog_input_proc)
118
205
  end
119
- add_cog_instance(cog_name, cog_instance)
206
+ add_cog_instance(cog_instance)
120
207
  end
121
208
 
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?
209
+ def bind_outputs
210
+ on_outputs_method = method(:on_outputs)
211
+ on_outputs_bang_method = method(:on_outputs!)
212
+ method_to_bind = proc { |&outputs_proc| on_outputs_method.call(outputs_proc) }
213
+ bang_method_to_bind = proc { |&outputs_proc| on_outputs_bang_method.call(outputs_proc) }
214
+ @execution_context.instance_eval do
215
+ define_singleton_method(:outputs, method_to_bind)
216
+ define_singleton_method(:outputs!, bang_method_to_bind)
217
+ end
218
+ end
219
+
220
+ #: (^(untyped, Integer) -> untyped) -> void
221
+ def on_outputs(outputs)
222
+ raise OutputsAlreadyDefinedError if @outputs || @outputs_bang
223
+
224
+ @outputs = outputs
225
+ end
126
226
 
127
- em = ExecutionManager.new(@cog_registry, @config_manager, @all_execution_procs, input.scope)
128
- em.prepare!
129
- em.run!
227
+ #: (^(untyped, Integer) -> untyped) -> void
228
+ def on_outputs!(outputs)
229
+ raise OutputsAlreadyDefinedError if @outputs || @outputs_bang
230
+
231
+ @outputs_bang = outputs
232
+ end
233
+
234
+ #: () -> untyped
235
+ def compute_final_output
236
+ return if @final_output_computed
237
+
238
+ @final_output_computed = true
239
+ outputs_proc = @outputs_bang || @outputs
240
+
241
+ @final_output = if outputs_proc
242
+ @cog_input_manager.context.instance_exec(@scope_value, @scope_index, &outputs_proc)
243
+ else
244
+ last_cog_name = @cog_stack.last&.name
245
+ raise CogInputManager::CogDoesNotExistError, "no cogs defined in scope" unless last_cog_name
130
246
 
131
- # TODO: collect the outputs of the cogs in the execution manager that just ran and do something with them
247
+ @cog_input_manager.send(:cog_output, last_cog_name)
132
248
  end
133
- Cogs::Execute.new(cog_name, cog_input_proc, trigger)
249
+ rescue ControlFlow::SkipCog, ControlFlow::Next
250
+ # TODO: do something with the message passed to the control flow statement
251
+ # Swallow skip! and next! control flow statements in the outputs block
252
+ # Calling these will just make the final output `nil`.
253
+ # (As will calling `break!`, but it gets handled elsewhere.)
254
+ # Calling `fail!` inside `outputs` should actually raise an exception.
255
+ rescue CogInputManager::CogNotYetRunError, CogInputManager::CogSkippedError, CogInputManager::CogStoppedError => e
256
+ # Attempting to accessing a cog that was skipped, stopped, or did not run from inside an `outputs` block
257
+ # is more likely to happen when the user `break!`s from a loop. Allowing this access not to result in an
258
+ # exception getting raised immediately will reduce boilerplate code needed to check if the loop was broken
259
+ # and return nil or some fallback value if it was, and the normal outputs value otherwise.
260
+ #
261
+ # Using `outputs` to define the scope's outputs will swallow these exceptions.
262
+ # Using `outputs!` instead will cause the exceptions to be raised.
263
+ raise e if @outputs_bang.present?
134
264
  end
135
265
  end
136
266
  end
@@ -0,0 +1,23 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Kernel
5
+ #: -> self
6
+ def not_nil!
7
+ self
8
+ end
9
+ end
10
+
11
+ class NilClass
12
+ # @override
13
+ #: -> bot
14
+ def not_nil!
15
+ raise UnexpectedNilError
16
+ end
17
+ end
18
+
19
+ class UnexpectedNilError < StandardError
20
+ def initialize(message = "Unexpected nil value encountered.")
21
+ super(message)
22
+ end
23
+ end
@@ -0,0 +1,32 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module DSL
6
+ class SystemCog
7
+ # Parameters for system cogs set at workflow evaluation time
8
+ #
9
+ # Params are used to provide limited evaluation-time parameterization to system cogs,
10
+ # such as the name of the execute scope to be invoked by a `call`, `map`, a, `repeat` cog.
11
+ #
12
+ # System cogs also accept input at execution time, just like regular cogs.
13
+ class Params
14
+ # The name identifier for this system cog instance
15
+ #
16
+ # Used to reference this cog's output. Auto-generated if not provided.
17
+ #
18
+ #: Symbol
19
+ attr_reader :name
20
+
21
+ # Initialize parameters with the cog name
22
+ #
23
+ # Subclasses should define their own `initialize` accepting specific parameters.
24
+ #
25
+ #: (Symbol?) -> void
26
+ def initialize(name)
27
+ @name = name || Cog.generate_fallback_name
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end