Skip to content

Commit 7885b6c

Browse files
feat: extend global config directive to accept name/pattern specifiers
Allow `global(:step_name)` and `global(/pattern/)` in Roast config blocks, mirroring the existing name/pattern specifier support in cog-type blocks (agent, chat, cmd, etc.). This enables targeted cross-cutting configuration like: global(:my_step) { async! } global(/^api_/) { abort_on_failure! } The merge cascade expands from 4 tiers to 7: 1. global (bare) 2. global (regexp match) 3. global (exact name) 4. cog-type general 5. cog-type regexp match 6. cog-type exact name 7. inline YAML Also cleans up a vestigial `instance_variable_get(:@values)` call to use the public `.values` accessor instead.
1 parent ea513b6 commit 7885b6c

3 files changed

Lines changed: 152 additions & 13 deletions

File tree

lib/roast/config_manager.rb

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ def initialize(cog_registry, config_procs, workflow_context)
1717
@workflow_context = workflow_context
1818
@config_context = ConfigContext.new #: ConfigContext
1919
@global_config = Cog::Config.new #: Cog::Config
20+
@global_regexp_configs = {} #: Hash[Regexp, Cog::Config]
21+
@global_name_configs = {} #: Hash[Symbol, Cog::Config]
2022
@general_configs = {} #: Hash[singleton(Cog), Cog::Config]
2123
@regexp_scoped_configs = {} #: Hash[singleton(Cog), Hash[Regexp, Cog::Config]]
2224
@name_scoped_configs = {} #: Hash[singleton(Cog), Hash[Symbol, Cog::Config]]
@@ -48,12 +50,25 @@ def config_for(cog_class, name = nil)
4850
raise ConfigManagerNotPreparedError unless prepared?
4951

5052
# All cogs will always have a config; empty by default if the cog was never explicitly configured
51-
config = cog_class.config_class.new(@global_config.instance_variable_get(:@values).deep_dup)
53+
# Start with bare global config
54+
config = cog_class.config_class.new(@global_config.values.deep_dup)
55+
unless name.nil?
56+
# Apply matching global regexp configs (insertion order)
57+
@global_regexp_configs.each do |pattern, cfg|
58+
config = config.merge(cfg) if pattern.match?(name.to_s)
59+
end
60+
# Apply matching global name config
61+
global_name_cfg = @global_name_configs[name]
62+
config = config.merge(global_name_cfg) if global_name_cfg
63+
end
64+
# Apply cog-type general config
5265
config = config.merge(fetch_general_config(cog_class))
53-
@regexp_scoped_configs.fetch(cog_class, {}).select do |pattern, _|
54-
pattern.match?(name.to_s) unless name.nil?
55-
end.values.each { |cfg| config = config.merge(cfg) }
66+
# Apply cog-type regexp configs
5667
unless name.nil?
68+
@regexp_scoped_configs.fetch(cog_class, {}).select do |pattern, _|
69+
pattern.match?(name.to_s)
70+
end.values.each { |cfg| config = config.merge(cfg) }
71+
# Apply cog-type name config
5772
name_scoped_config = fetch_name_scoped_config(cog_class, name)
5873
config = config.merge(name_scoped_config)
5974
end
@@ -127,19 +142,30 @@ def on_config(cog_class, cog_name_or_pattern, cog_config_proc)
127142

128143
def bind_global
129144
on_global_method = method(:on_global)
130-
method_to_bind = proc do |&global_proc|
131-
on_global_method.call(global_proc)
145+
method_to_bind = proc do |name_or_pattern = nil, &global_proc|
146+
on_global_method.call(name_or_pattern, global_proc)
132147
end
133148
@config_context.instance_eval do
134149
define_singleton_method(:global, method_to_bind)
135150
end
136151
end
137152

138-
#: (^() -> void ) -> void
139-
def on_global(global_config_proc)
153+
#: ((Symbol | Regexp)?, ^() -> void) -> void
154+
def on_global(name_or_pattern, global_config_proc)
155+
name_or_pattern = name_or_pattern #: untyped
156+
config_object = case name_or_pattern
157+
when NilClass
158+
@global_config
159+
when Regexp
160+
@global_regexp_configs[name_or_pattern] ||= Cog::Config.new
161+
when Symbol
162+
@global_name_configs[name_or_pattern] ||= Cog::Config.new
163+
else
164+
raise ArgumentError, "Invalid type '#{name_or_pattern.class}' for global name_or_pattern"
165+
end
140166
global_config_proc = global_config_proc #: as ^(untyped) -> void
141-
bind_workflow_params(@global_config)
142-
@global_config.instance_exec(&global_config_proc) if global_config_proc
167+
bind_workflow_params(config_object)
168+
config_object.instance_exec(&global_config_proc) if global_config_proc
143169
nil
144170
end
145171

sorbet/rbi/shims/lib/roast/config_context.rbi

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
module Roast
55
class ConfigContext
6-
76
# Configure all cogs globally with shared settings
87
#
98
# Apply configuration that affects all cog instances in the workflow. Configuration
@@ -16,6 +15,14 @@ module Roast
1615
# global do
1716
# # Configuration here applies to all cogs
1817
# end
18+
#
19+
# global(:step_name) do
20+
# # Configuration here applies to all cogs named :step_name
21+
# end
22+
#
23+
# global(/pattern/) do
24+
# # Configuration here applies to all cogs whose name matches /pattern/
25+
# end
1926
# end
2027
# ```
2128
#
@@ -35,8 +42,8 @@ module Roast
3542
# - `working_directory(path)` - Set the working directory for external commands invoked by the cog
3643
# - `use_current_working_directory!` - Use the current working directory
3744
#
38-
#: () {() [self: Roast::Cog::Config] -> void} -> void
39-
def global(&block); end
45+
#: (?(Symbol | Regexp)?) {() [self: Roast::Cog::Config] -> void} -> void
46+
def global(name_or_pattern = nil, &block); end
4047

4148
# Configure the `call` cog
4249
#

test/roast/config_manager_test.rb

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,112 @@ def build_manager(config_procs = [], params: WorkflowParams.new([], [], {}))
291291
assert manager.config_for(TestCog).abort_on_failure?
292292
end
293293

294+
test "global with name specifier applies only to matching cog name" do
295+
config_proc = proc do
296+
global(:my_step) { self[:marker] = "named_global" }
297+
end
298+
manager = build_manager([config_proc])
299+
manager.prepare!
300+
301+
matching_config = manager.config_for(TestCog, :my_step)
302+
non_matching_config = manager.config_for(TestCog, :other_step)
303+
304+
assert_equal "named_global", matching_config.values[:marker]
305+
assert_nil non_matching_config.values[:marker]
306+
end
307+
308+
test "global with regexp specifier applies to matching cog names" do
309+
config_proc = proc do
310+
global(/^api_/) { async! }
311+
end
312+
manager = build_manager([config_proc])
313+
manager.prepare!
314+
315+
matching_config = manager.config_for(TestCog, :api_call)
316+
non_matching_config = manager.config_for(TestCog, :db_query)
317+
318+
assert matching_config.async?
319+
refute non_matching_config.async?
320+
end
321+
322+
test "global with name specifier does not apply when name is nil" do
323+
config_proc = proc do
324+
global(:my_step) { async! }
325+
end
326+
manager = build_manager([config_proc])
327+
manager.prepare!
328+
329+
config = manager.config_for(TestCog)
330+
331+
refute config.async?
332+
end
333+
334+
test "global regexp does not apply when cog name is nil" do
335+
config_proc = proc do
336+
global(/.*/) { async! }
337+
end
338+
manager = build_manager([config_proc])
339+
manager.prepare!
340+
341+
config = manager.config_for(TestCog)
342+
343+
refute config.async?
344+
end
345+
346+
test "global cascade order: bare < regexp < name" do
347+
config_proc = proc do
348+
global { self[:priority] = "bare" }
349+
global(/my/) { self[:priority] = "regexp" }
350+
global(:my_step) { self[:priority] = "name" }
351+
end
352+
manager = build_manager([config_proc])
353+
manager.prepare!
354+
355+
# Named global should win over regexp and bare
356+
config = manager.config_for(TestCog, :my_step)
357+
assert_equal "name", config.values[:priority]
358+
end
359+
360+
test "multiple global regexps apply in insertion order" do
361+
config_proc = proc do
362+
global(/^a/) { self[:priority] = "first" }
363+
global(/api/) { self[:priority] = "second" }
364+
end
365+
manager = build_manager([config_proc])
366+
manager.prepare!
367+
368+
# Both match :api_call — second one wins (applied last)
369+
config = manager.config_for(TestCog, :api_call)
370+
assert_equal "second", config.values[:priority]
371+
end
372+
373+
test "global name config is overridden by cog-specific name config" do
374+
config_proc = proc do
375+
global(:my_step) { self[:marker] = "global" }
376+
test_cog(:my_step) { timeout 90 }
377+
end
378+
manager = build_manager([config_proc])
379+
manager.prepare!
380+
381+
config = manager.config_for(TestCog, :my_step)
382+
383+
# Cog-specific name config takes precedence for timeout
384+
assert_equal 90, config.timeout
385+
# Global name config's marker is also present (merged)
386+
assert_equal "global", config.values[:marker]
387+
end
388+
389+
test "workflow params are accessible inside a global name config block" do
390+
captured = nil
391+
config_proc = proc do
392+
global(:my_step) { captured = target! }
393+
end
394+
manager = build_manager([config_proc], params: WorkflowParams.new(["Gemfile"], [], {}))
395+
manager.prepare!
396+
397+
assert_equal "Gemfile", captured
398+
end
399+
294400
test "workflow params are not accessible in the top-level config block body" do
295401
config_proc = proc do
296402
args

0 commit comments

Comments
 (0)