Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ When creating GitHub issues, always check available labels, projects, and milest

```bash
# List all available labels
gh api repos/Shopify/roast/labels | jq '.[].name'
gh label list --repo Shopify/roast --json name --jq '.[].name'

# List all milestones
gh api repos/Shopify/roast/milestones | jq '.[] | {title: .title, number: .number, state: .state}'
Expand Down
48 changes: 36 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,8 +528,8 @@ This feature is particularly useful when:
- Resuming after failures in long-running workflows

**Storage Locations:**
- SQLite: `~/.roast/sessions.db` (configurable via `ROAST_SESSIONS_DB`)
- Filesystem: `.roast/sessions/` directory in your project
- SQLite: `$XDG_DATA_HOME/roast/sessions.db` (default: `~/.local/share/roast/sessions.db`, configurable via `ROAST_SESSIONS_DB`)
- Filesystem: `$XDG_DATA_HOME/roast/sessions/` (default: `~/.local/share/roast/sessions/`)

#### Target Option (`-t, --target`)

Expand Down Expand Up @@ -1082,10 +1082,11 @@ See the [MCP tools example](examples/mcp/) for complete documentation and more e

### Custom Tools

You can create your own tools using the [Raix function dispatch pattern](https://github.com/OlympiaAI/raix-rails?tab=readme-ov-file#use-of-toolsfunctions). Custom tools should be placed in `.roast/initializers/` (subdirectories are supported):
You can create your own tools using the [Raix function dispatch pattern](https://github.com/OlympiaAI/raix-rails?tab=readme-ov-file#use-of-toolsfunctions). Custom tools should be placed in initializers directories (subdirectories are supported):

```ruby
# .roast/initializers/tools/git_analyzer.rb
# ~/.config/roast/initializers/tools/git_analyzer.rb
# OR {workflow_directory}/initializers/tools/git_analyzer.rb
module MyProject
module Tools
module GitAnalyzer
Expand Down Expand Up @@ -1133,22 +1134,45 @@ The tool will be available to the AI model during workflow execution, and it can

### Project-specific Configuration

You can extend Roast with project-specific configuration by creating initializers in `.roast/initializers/`. These are automatically loaded when workflows run, allowing you to:
You can extend Roast with project-specific configuration by creating initializers. These are automatically loaded when workflows run, allowing you to:

- Add custom instrumentation
- Configure monitoring and metrics
- Set up project-specific tools
- Customize workflow behavior

Example structure:
Roast supports initializers in multiple locations (in priority order):

1. **Workflow-local initializers**: Place alongside your workflow steps
2. **Global XDG config**: Shared across all projects using XDG Base Directory specification

Example structures:
```
your-project/
├── .roast/
│ └── initializers/
│ ├── metrics.rb
│ ├── logging.rb
│ └── custom_tools.rb
# Workflow-local (highest priority)
your-workflow/
├── workflow.yml
├── analyze_code/
├── initializers/
│ ├── metrics.rb
│ ├── logging.rb
│ └── custom_tools.rb
└── ...

# Global XDG config (user-wide)
~/.config/roast/
└── initializers/
├── shopify_defaults.rb
└── custom_tools.rb
```

**XDG Configuration Directories:**
- Config: `$XDG_CONFIG_HOME/roast` (default: `~/.config/roast`)
- Cache: `$XDG_CACHE_HOME/roast` (default: `~/.cache/roast`)

You can customize these locations by setting XDG environment variables:
```bash
export XDG_CONFIG_HOME="/custom/config"
export XDG_CACHE_HOME="/fast/ssd/cache"
```

### Pre/Post Processing Framework
Expand Down
81 changes: 81 additions & 0 deletions lib/roast.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,29 @@

# Set up Zeitwerk autoloader
loader = Zeitwerk::Loader.for_gem

# Configure custom inflector for XDG acronym
loader.inflector.inflect("xdg_migration" => "XDGMigration")

loader.setup

module Roast
ROOT = File.expand_path("../..", __FILE__)

# https://specifications.freedesktop.org/basedir-spec/latest/
XDG_CONFIG_HOME = ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config"))
XDG_CACHE_HOME = ENV.fetch("XDG_CACHE_HOME", File.join(Dir.home, ".cache"))
XDG_DATA_HOME = ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share"))

CONFIG_DIR = File.join(XDG_CONFIG_HOME, "roast")
CACHE_DIR = File.join(XDG_CACHE_HOME, "roast")
DATA_DIR = File.join(XDG_DATA_HOME, "roast")

GLOBAL_INITIALIZERS_DIR = File.join(CONFIG_DIR, "initializers")
FUNCTION_CACHE_DIR = File.join(CACHE_DIR, "function_calls")
SESSION_DATA_DIR = File.join(DATA_DIR, "sessions")
SESSION_DB_PATH = ENV.fetch("ROAST_SESSIONS_DB", File.join(DATA_DIR, "sessions.db"))

class CLI < Thor
desc "execute [WORKFLOW_CONFIGURATION_FILE] [FILES...]", "Run a configured workflow"
option :concise, type: :boolean, aliases: "-c", desc: "Optional flag for use in output templates"
Expand All @@ -66,6 +84,8 @@ def execute(*paths)
File.expand_path("roast/#{workflow_path}/workflow.yml")
end

Roast::XDGMigration.warn_if_migration_needed(expanded_workflow_path)

raise Thor::Error, "Expected a Roast workflow configuration file, got directory: #{expanded_workflow_path}" if File.directory?(expanded_workflow_path)

Roast::Workflow::ConfigurationParser.new(expanded_workflow_path, files, options.transform_keys(&:to_sym)).begin!
Expand All @@ -82,6 +102,8 @@ def resume(workflow_path)
File.expand_path("roast/#{workflow_path}/workflow.yml")
end

Roast::XDGMigration.warn_if_migration_needed(expanded_workflow_path)

unless File.exist?(expanded_workflow_path)
raise Thor::Error, "Workflow file not found: #{expanded_workflow_path}"
end
Expand Down Expand Up @@ -141,6 +163,8 @@ def list
puts "Available workflows:"
puts

Roast::XDGMigration.warn_if_migration_needed

workflow_files.each do |file|
workflow_name = File.dirname(file.sub("#{roast_dir}/", ""))
puts " #{workflow_name} (from project)"
Expand All @@ -153,6 +177,8 @@ def list
desc "validate [WORKFLOW_CONFIGURATION_FILE]", "Validate a workflow configuration"
option :strict, type: :boolean, aliases: "-s", desc: "Treat warnings as errors"
def validate(workflow_path = nil)
Roast::XDGMigration.warn_if_migration_needed(workflow_path)

validation_command = Roast::Workflow::ValidationCommand.new(options)
validation_command.execute(workflow_path)
end
Expand Down Expand Up @@ -204,6 +230,8 @@ def sessions

desc "session SESSION_ID", "Show details for a specific session"
def session(session_id)
Roast::XDGMigration.warn_if_migration_needed

repository = Workflow::StateRepositoryFactory.create

unless repository.respond_to?(:get_session_details)
Expand Down Expand Up @@ -254,6 +282,8 @@ def session(session_id)
desc "diagram WORKFLOW_FILE", "Generate a visual diagram of a workflow"
option :output, type: :string, aliases: "-o", desc: "Output file path (defaults to workflow_name_diagram.png)"
def diagram(workflow_file)
Roast::XDGMigration.warn_if_migration_needed(workflow_file)

unless File.exist?(workflow_file)
raise Thor::Error, "Workflow file not found: #{workflow_file}"
end
Expand All @@ -267,8 +297,59 @@ def diagram(workflow_file)
raise Thor::Error, "Error generating diagram: #{e.message}"
end

desc "xdg-migrate [WORKFLOW_PATH]", "Migrate legacy .roast directories to XDG directories"
option :auto_confirm, type: :boolean, aliases: "-a", desc: "Automatically confirm all prompts"
def xdg_migrate(workflow_path = nil)
workflow_file_path = if workflow_path.nil?
Roast::Helpers::Logger.warn(::CLI::UI.fmt("{{yellow:No workflow path provided, will not be able to migrate initializers.}}"))
nil
elsif workflow_path.include?("workflow.yml")
File.expand_path(workflow_path)
else
File.expand_path("roast/#{workflow_path}/workflow.yml")
end

if workflow_file_path && !File.exist?(workflow_file_path)
raise Thor::Error, "Workflow file not found: #{workflow_file_path}"
end

workflow_context_path = File.dirname(workflow_file_path) if workflow_file_path

roast_dirs = Roast::XDGMigration.find_all_legacy_dot_roast_dirs(workflow_context_path || Dir.pwd)
dot_roast_path = run_roast_dir_picker(roast_dirs) unless roast_dirs.empty?

if dot_roast_path.nil?
Roast::Helpers::Logger.info("No legacy .roast directories found.")
return
end

# Migrate specific .roast directory
migration = Roast::XDGMigration.new(
dot_roast_path:,
workflow_context_path:,
auto_confirm: options[:auto_confirm],
)

migration.migrate
end

private

def run_roast_dir_picker(roast_dirs)
choices = roast_dirs.map { |dir| File.dirname(dir) + "/.roast" }
choices << "Cancel"

selected = ::CLI::UI::Prompt.ask("Select a .roast directory to migrate:") do |handler|
choices.each { |choice| handler.option(choice) { |selection| selection } }
end

return if selected == "Cancel"

# Find the full path for the selected directory
selected_index = choices.index(selected)
roast_dirs[selected_index]
end

def show_example_picker
examples = available_examples

Expand Down
6 changes: 3 additions & 3 deletions lib/roast/helpers/function_caching_interceptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ def dispatch_tool_function(function_name, params)
})

# Handle workflows with or without configuration
result = if !respond_to?(:configuration) || configuration.nil?
result = if !respond_to?(:workflow_configuration) || workflow_configuration.nil?
super(function_name, params)
else
function_config = if configuration.respond_to?(:function_config)
configuration.function_config(function_name)
function_config = if workflow_configuration.respond_to?(:function_config)
workflow_configuration.function_config(function_name)
else
{}
end
Expand Down
2 changes: 1 addition & 1 deletion lib/roast/helpers/logger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def create_logger(stdout)
msg_string = format_message(msg)

if severity == "INFO" && !msg_string.start_with?("[")
msg_string
msg_string + "\n"
else
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity}: #{msg_string.gsub(/^\[|\]$/, "").strip}\n"
end
Expand Down
60 changes: 36 additions & 24 deletions lib/roast/initializers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,48 @@
module Roast
class Initializers
class << self
def config_root(starting_path = Dir.pwd, ending_path = File.dirname(Dir.home))
paths = []
candidate = starting_path
while candidate != ending_path
paths << File.join(candidate, ".roast")
candidate = File.dirname(candidate)
def load_all(workflow_context_path = Dir.pwd)
# .reverse so we load the highest priority files last, letting them override lower priority files
initializer_files(workflow_context_path).reverse.each do |file|
load_initializer(file)
end

first_existing = paths.find { |path| Dir.exist?(path) }
first_existing || paths.first
rescue => e
puts "ERROR: Error loading initializers: #{e.message}"
Roast::Helpers::Logger.error("Error loading initializers: #{e.message}")
# Don't fail the workflow if initializers can't be loaded
end

def initializers_path
File.join(Roast::Initializers.config_root, "initializers")
end
private

def load_all
project_initializers = Roast::Initializers.initializers_path
return unless Dir.exist?(project_initializers)
# Get all possible initializer directories in priority order
def initializer_files(workflow_context_path = Dir.pwd)
initializer_files = []
# 1. Workflow-local initializers (highest priority)
local_dir = local_initializers_dir(workflow_context_path)
if Dir.exist?(local_dir)
initializer_files.concat(Dir.glob(File.join(local_dir, "**/*.rb")))
end

$stderr.puts "Loading project initializers from #{project_initializers}"
pattern = File.join(project_initializers, "**/*.rb")
Dir.glob(pattern, sort: true).each do |file|
$stderr.puts "Loading initializer: #{file}"
require file
# 2. XDG global config initializers
if Dir.exist?(Roast::GLOBAL_INITIALIZERS_DIR)
initializer_files.concat(Dir.glob(File.join(Roast::GLOBAL_INITIALIZERS_DIR, "**/*.rb")))
end
rescue => e
puts "ERROR: Error loading initializers: #{e.message}"
Roast::Helpers::Logger.error("Error loading initializers: #{e.message}")
# Don't fail the workflow if initializers can't be loaded

# 3. Legacy .roast directory support
initializer_files.concat(Roast::XDGMigration.new.legacy_initializers)

unique_initializer_files = initializer_files.uniq { |file| File.basename(file) }

unique_initializer_files
end

def local_initializers_dir(workflow_context_path)
File.join(workflow_context_path, "initializers")
end

def load_initializer(file)
Roast::Helpers::Logger.info("Loading initializer: #{file}")
require file
end
end
end
Expand Down
11 changes: 2 additions & 9 deletions lib/roast/tools.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,8 @@ module Roast
module Tools
extend self

# Initialize cache and ensure .gitignore exists
cache_dir = File.join(Dir.pwd, ".roast", "cache")
FileUtils.mkdir_p(cache_dir) unless File.directory?(cache_dir)

# Add .gitignore to cache directory
gitignore_path = File.join(cache_dir, ".gitignore")
File.write(gitignore_path, "*") unless File.exist?(gitignore_path)

CACHE = ActiveSupport::Cache::FileStore.new(cache_dir)
# Initialize cache using XDG cache directory
CACHE = ActiveSupport::Cache::FileStore.new(FUNCTION_CACHE_DIR)

def file_to_prompt(file)
<<~PROMPT
Expand Down
2 changes: 1 addition & 1 deletion lib/roast/workflow/session_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def workflow_directory(session_name, file_path)
file_id = Digest::MD5.hexdigest(file_path || Dir.pwd)
file_basename = File.basename(file_path || Dir.pwd).parameterize.underscore
human_readable_id = "#{file_basename}_#{file_id[0..7]}"
File.join(Dir.pwd, ".roast", "sessions", workflow_dir_name, human_readable_id)
File.join(SESSION_DATA_DIR, workflow_dir_name, human_readable_id)
end

def find_latest_session_directory(workflow_dir)
Expand Down
14 changes: 11 additions & 3 deletions lib/roast/workflow/sqlite_state_repository.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ module Workflow
# SQLite-based implementation of StateRepository
# Provides structured, queryable session storage with better performance
class SqliteStateRepository < StateRepository
DEFAULT_DB_PATH = File.expand_path("~/.roast/sessions.db")

def initialize(db_path: nil, session_manager: SessionManager.new)
super()

Expand All @@ -19,7 +17,17 @@ def initialize(db_path: nil, session_manager: SessionManager.new)
raise LoadError, "SQLite storage requires the 'sqlite3' gem. Please add it to your Gemfile or install it: gem install sqlite3"
end

@db_path = db_path || ENV["ROAST_SESSIONS_DB"] || DEFAULT_DB_PATH
# Priority order
paths = [
db_path,
Roast::SESSION_DB_PATH,
Roast::XDGMigration.new.legacy_sessions_db_path,
]

# If multiple options exist, prefer the first one that exists
@db_path = paths.find { |path| path && File.exist?(path) }
# If no options exist as paths, use the first one that is not nil
@db_path ||= paths.find { |path| !path.nil? }
@session_manager = session_manager
ensure_database
end
Expand Down
2 changes: 1 addition & 1 deletion lib/roast/workflow/workflow_initializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def setup
private

def load_roast_initializers
Roast::Initializers.load_all
Roast::Initializers.load_all(@configuration.context_path)
end

def check_raix_configuration
Expand Down
Loading