Skip to content
Merged
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
24 changes: 24 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,37 @@ This Rails app uses a small set of preferred libraries for common integration wo
- **Acronyms**: Use standard inflection (e.g., `rss.rb` → `Rss`, `api_client.rb` → `ApiClient`) unless a custom inflection is explicitly defined in `config/initializers/inflections.rb`.
- **Validation**: Always ensure the filename and the class/module name are perfectly aligned to avoid `NameError` during autoloading.

### Autoloading

- Everything in `lib/r3x/` and `app/lib/r3x/` is autoloaded by Zeitwerk. You should never need to use `require` or `require_relative` for files within these paths.
- **Bad**: `require_relative "../validators/cron"` at the top of a file in `lib/r3x/triggers/`
- **Good**: Just reference `R3x::Validators::Cron` directly - Zeitwerk will find and load it automatically.
- The only exception is requiring external gems that don't auto-require their components.
- **Debugging**: If you get a `NameError` when referencing a class that should exist, it's likely a Zeitwerk autoloading issue (wrong file name, wrong constant name, or missing namespace). Check that file names match constants exactly (snake_case ↔ CamelCase).

## Testing

- When writing tests for workflow DSL or infrastructure, use generic workflow names (e.g., `TestWorkflow`, `MyTestWorkflow`), not real workflow names from `workflows/` folder.
- Real workflows in `workflows/` are "user workflows" and should not be hardcoded in tests for the core framework.
- Use anonymous classes or fixture workflows in `test/fixtures/workflows/` for testing framework behavior.
- **Good**: `Class.new(R3x::Workflow) { def self.name; "Test"; end }`
- **Bad**: Testing `MyUserWorkflow` workflow directly in framework tests

## Logging

- Use Rails tagged logging with `self.class.name` for per-class log prefixes.
- **Good**: `logger.tagged(self.class.name) { logger.info("message") }`
- **Bad**: `logger.info("[Hardcoded::Class::Name] message")` or manual string interpolation
- Reasoning: Using `self.class.name` keeps log tags synchronized with actual class names automatically, supports nested tagging, and works consistently with Rails log formatting.

## Validators

- Place shared validation logic in `lib/r3x/validators/`.
- **Good**: `R3x::Validators::Cron`, `R3x::Validators::Url`
- **Bad**: `R3x::Triggers::CronValidator`, `R3x::Services::UrlChecker`
- Reasoning: Validators are reusable across triggers, services, and other components. Keep them in a dedicated namespace.
- Pattern: Each validator should expose a `validate!(value, field_name: "field")` class method that raises `ArgumentError` on invalid input.

## Control Flow

- `case` statements that dispatch on configuration values (e.g., ENV modes) must either exhaustively list all supported values or raise an exception in the `else` branch for unsupported values.
Expand Down
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ gem "google-apis-gmail_v1"
gem "solid_cache"
gem "solid_queue"

# Active Job dashboard (requires propshaft for API-only apps)
gem "mission_control-jobs"
gem "propshaft"

# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false

Expand Down
30 changes: 30 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ GEM
grpc (~> 1.41)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
importmap-rails (2.2.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.2)
irb (1.17.0)
pp (>= 0.6.0)
Expand Down Expand Up @@ -268,6 +272,16 @@ GEM
minitest (6.0.2)
drb (~> 2.0)
prism (~> 1.5)
mission_control-jobs (1.1.0)
actioncable (>= 7.1)
actionpack (>= 7.1)
activejob (>= 7.1)
activerecord (>= 7.1)
importmap-rails (>= 1.2.1)
irb (~> 1.13)
railties (>= 7.1)
stimulus-rails
turbo-rails
msgpack (1.8.0)
multi_json (1.19.1)
multipart-post (2.4.1)
Expand Down Expand Up @@ -308,6 +322,10 @@ GEM
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
psych (5.3.1)
date
stringio
Expand Down Expand Up @@ -434,11 +452,16 @@ GEM
sqlite3 (2.9.1-x86_64-darwin)
sqlite3 (2.9.1-x86_64-linux-gnu)
sqlite3 (2.9.1-x86_64-linux-musl)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
thor (1.5.0)
timeout (0.6.1)
trailblazer-option (0.1.2)
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
Expand Down Expand Up @@ -474,8 +497,10 @@ DEPENDENCIES
google-apis-gmail_v1
google-cloud-translate
googleauth
mission_control-jobs
multi_json
nokogiri
propshaft
puma (>= 5.0)
rails (~> 8.1.2)
rubocop-rails-omakase
Expand Down Expand Up @@ -557,6 +582,7 @@ CHECKSUMS
grpc (1.78.1-x86_64-linux-musl) sha256=4e90a3d0f2baa1cd70756ffe116bd81bb7b398ff774272828a23a89ac9a30b06
grpc-google-iam-v1 (1.11.0) sha256=8f0aa8a8503b3e001cb1561f31e43aa0445752fb675334afa1afac7f023f368c
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae
json (2.19.1) sha256=dd94fdc59e48bff85913829a32350b3148156bc4fd2a95a2568a78b11344082d
Expand All @@ -571,6 +597,7 @@ CHECKSUMS
mcp (0.8.0) sha256=ae8bd146bb8e168852866fd26f805f52744f6326afb3211e073f78a95e0c34fb
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (6.0.2) sha256=db6e57956f6ecc6134683b4c87467d6dd792323c7f0eea7b93f66bd284adbc3d
mission_control-jobs (1.1.0) sha256=b13da9cde3344fec7c744b79d2a34c3ecd454f45d4d593d4c968ba762315e84c
msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732
multi_json (1.19.1) sha256=7aefeff8f2c854bf739931a238e4aea64592845e0c0395c8a7d2eea7fdd631b7
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
Expand All @@ -594,6 +621,7 @@ CHECKSUMS
pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392
psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8
Expand Down Expand Up @@ -634,11 +662,13 @@ CHECKSUMS
sqlite3 (2.9.1-x86_64-darwin) sha256=5ce2c05eed8dc7c6debd560e2c5960e36521652b9a43bc3e42bc431db600c36f
sqlite3 (2.9.1-x86_64-linux-gnu) sha256=1cbb644204ed143e5c96f6d59b5c571ba6f18b18a9dc5aa11c101187ff227afd
sqlite3 (2.9.1-x86_64-linux-musl) sha256=bbd50dd1caca78b6c069701d9009ef714461495985d4c374ea1a1def061ba67c
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
Expand Down
6 changes: 6 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
up:
rails s

workflows-run:
rails r3x:workflows:run

workflows-list:
rails r3x:workflows:list
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ bin/rails db:setup
bin/rails server
```

Then open http://localhost:3000/ to view the Mission Control Jobs dashboard.

## Test

```bash
Expand Down
5 changes: 3 additions & 2 deletions app/jobs/r3x/run_workflow_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ module R3x
class RunWorkflowJob < ApplicationJob
queue_as :default

def perform(workflow_key)
def perform(workflow_key, triggered_by: nil)
R3x::WorkflowPackLoader.load!
workflow_class = R3x::WorkflowRegistry.fetch(workflow_key)
workflow_class.new.run(R3x::WorkflowContext.new)
triggered_by_obj = triggered_by ? TriggeredBy.new(triggered_by) : nil
workflow_class.new.run(R3x::WorkflowContext.new(triggered_by: triggered_by_obj))
end
end
end
2 changes: 1 addition & 1 deletion app/lib/r3x/outputs/discord.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def deliver(content:)
raise ArgumentError, "Missing Discord webhook URL" if webhook_url.blank?
R3x::Services::DiscordWebhookClient.new(webhook_url: webhook_url).deliver(content: content)
when "test"
logger.info("[DISCORD OUTPUT] #{content}")
logger.info(content)
else
raise ArgumentError, "Unsupported Discord mode: #{mode}. Supported: real, test"
end
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module R3x
module Triggers
class Rss
module Services
class RssParser
def parse(body, source_url:)
doc = Nokogiri::XML(body)

Expand Down
3 changes: 3 additions & 0 deletions config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,7 @@

config.active_job.queue_adapter = :solid_queue
config.solid_queue.logger = ActiveSupport::Logger.new(STDOUT)

# Disable Mission Control Jobs HTTP Basic auth in development
config.mission_control.jobs.http_basic_auth_enabled = false
end
5 changes: 5 additions & 0 deletions config/initializers/r3x_workflow_loader.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
require Rails.root.join("lib/r3x/workflow_pack_loader")
require Rails.root.join("lib/r3x/workflow")
require Rails.root.join("lib/r3x/recurring_tasks_config")

# Validators for triggers and other components
Dir[Rails.root.join("lib/r3x/validators/*.rb")].each { |f| require f }

Rails.application.config.after_initialize do
R3x::WorkflowPackLoader.load!
Expand Down
39 changes: 24 additions & 15 deletions config/recurring.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
development:
porto_santo_news:
class: R3x::RunWorkflowJob
args: [porto_santo_news]
schedule: "*/15 * * * *"
queue: default
<% require "fugit" %>
<%
# Static maintenance tasks (always present)
static_tasks = {
"clear_solid_queue_finished_jobs" => {
"command" => "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)",
"schedule" => "every hour at minute 12",
"queue" => "default"
}
}

production:
porto_santo_news:
class: R3x::RunWorkflowJob
args: [porto_santo_news]
schedule: "*/15 * * * *"
queue: default
# Load workflows and generate recurring tasks from DSL
R3x::WorkflowPackLoader.load!
dynamic_tasks = R3x::RecurringTasksConfig.to_h

clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12
# Merge static and dynamic tasks
all_tasks = static_tasks.merge(dynamic_tasks)

# Generate for all environments
config = {
"development" => all_tasks,
"production" => all_tasks,
"test" => {} # No recurring tasks in test
}
%>
<%= config.to_yaml %>
11 changes: 6 additions & 5 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Mount Mission Control Jobs dashboard
mount MissionControl::Jobs::Engine, at: "/jobs"

# Root redirects to Mission Control Jobs dashboard (using unnamed route to hide from Mission Control)
get "/", to: redirect("/jobs")

# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check

# Defines the root path route ("/")
# root "posts#index"
get "up" => "rails/health#show", :as => :rails_health_check
end
25 changes: 25 additions & 0 deletions lib/r3x/recurring_tasks_config.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
module R3x
class RecurringTasksConfig
class << self
def to_h
result = {}

WorkflowRegistry.all.each do |workflow_class|
schedule = workflow_class.schedule_trigger
next unless schedule
Comment on lines +8 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include RSS triggers when building recurring tasks

to_h currently gates every workflow on schedule_trigger and skips it otherwise, so workflows configured with trigger :rss never get a recurring task entry at all. In this commit, rss_triggers is defined and validated but not consumed by any scheduler path, so RSS-triggered workflows will not execute automatically in production despite being accepted by the DSL.

Useful? React with 👍 / 👎.


workflow_key = workflow_class.workflow_key

result[workflow_key] = {
"class" => "R3x::RunWorkflowJob",
"args" => [ workflow_key, { "triggered_by" => "schedule" } ],
"schedule" => schedule.cron,
"queue" => "default"
}
end

result
end
end
end
end
27 changes: 27 additions & 0 deletions lib/r3x/triggered_by.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module R3x
class TriggeredBy
attr_reader :type

def initialize(type)
@type = type.to_sym
end

def schedule?
type == :schedule
end

def rss?
type == :rss
end

def manual?
type == :manual
end

def ==(other)
return type == other if other.is_a?(Symbol)
return type == other.type if other.is_a?(TriggeredBy)
false
end
end
end
42 changes: 42 additions & 0 deletions lib/r3x/triggers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module R3x
module Triggers
class << self
def supported_types
trigger_files.filter_map do |file|
basename = File.basename(file, ".rb")
next if basename == "base"
basename.to_sym
end.sort
end

def resolve(type)
type_sym = type.to_sym
supported = supported_types

unless supported.include?(type_sym)
raise ArgumentError, "Unknown trigger type: #{type}. No file found for trigger '#{type_sym}.rb' in #{triggers_dir}. " \
"Supported types: #{supported.map { |t| ":#{t}" }.join(", ")}"
end

class_name = type.to_s.camelize
full_class_name = "::R3x::Triggers::#{class_name}"

begin
full_class_name.constantize
rescue NameError
raise ArgumentError, "Trigger file '#{type_sym}.rb' exists but class #{full_class_name} is not defined or failed to load."
end
end

private

def triggers_dir
File.expand_path("triggers", __dir__)
end

def trigger_files
Dir.glob(File.join(triggers_dir, "*.rb"))
end
end
end
end
20 changes: 20 additions & 0 deletions lib/r3x/triggers/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module R3x
module Triggers
class Base
attr_reader :type, :options

def initialize(type, **options)
@type = type
@options = options
end

def validate!
raise NotImplementedError, "#{self.class.name} must implement validate!"
end

def to_h
{ type: type }.merge(options)
end
end
end
end
Loading
Loading