diff --git a/AGENTS.md b/AGENTS.md index c27e89e7..5164a553 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,22 @@ 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. @@ -37,6 +53,14 @@ This Rails app uses a small set of preferred libraries for common integration wo - **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. diff --git a/Gemfile b/Gemfile index 3b8dcdb7..d13adbfd 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index ee265388..cb77b94b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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 @@ -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) @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/Justfile b/Justfile index 08ef667e..b945686c 100644 --- a/Justfile +++ b/Justfile @@ -1,2 +1,8 @@ +up: + rails s + workflows-run: rails r3x:workflows:run + +workflows-list: + rails r3x:workflows:list diff --git a/README.md b/README.md index aca9a534..311404a2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/jobs/r3x/run_workflow_job.rb b/app/jobs/r3x/run_workflow_job.rb index 053b146d..32dc5b22 100644 --- a/app/jobs/r3x/run_workflow_job.rb +++ b/app/jobs/r3x/run_workflow_job.rb @@ -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 diff --git a/app/lib/r3x/outputs/discord.rb b/app/lib/r3x/outputs/discord.rb index 078aff7a..062d2045 100644 --- a/app/lib/r3x/outputs/discord.rb +++ b/app/lib/r3x/outputs/discord.rb @@ -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 diff --git a/app/lib/r3x/triggers/rss.rb b/app/lib/r3x/services/rss_parser.rb similarity index 95% rename from app/lib/r3x/triggers/rss.rb rename to app/lib/r3x/services/rss_parser.rb index a4e5bd08..3543c6fb 100644 --- a/app/lib/r3x/triggers/rss.rb +++ b/app/lib/r3x/services/rss_parser.rb @@ -1,6 +1,6 @@ module R3x - module Triggers - class Rss + module Services + class RssParser def parse(body, source_url:) doc = Nokogiri::XML(body) diff --git a/config/environments/development.rb b/config/environments/development.rb index 9b06b514..32f91d79 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -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 diff --git a/config/initializers/r3x_workflow_loader.rb b/config/initializers/r3x_workflow_loader.rb index bf74e746..73172e66 100644 --- a/config/initializers/r3x_workflow_loader.rb +++ b/config/initializers/r3x_workflow_loader.rb @@ -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! diff --git a/config/recurring.yml b/config/recurring.yml index c0739150..5d880493 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -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 %> diff --git a/config/routes.rb b/config/routes.rb index a125ef08..eeffca15 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/lib/r3x/recurring_tasks_config.rb b/lib/r3x/recurring_tasks_config.rb new file mode 100644 index 00000000..32695260 --- /dev/null +++ b/lib/r3x/recurring_tasks_config.rb @@ -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 + + 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 diff --git a/lib/r3x/triggered_by.rb b/lib/r3x/triggered_by.rb new file mode 100644 index 00000000..3675272a --- /dev/null +++ b/lib/r3x/triggered_by.rb @@ -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 diff --git a/lib/r3x/triggers.rb b/lib/r3x/triggers.rb new file mode 100644 index 00000000..dd299225 --- /dev/null +++ b/lib/r3x/triggers.rb @@ -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 diff --git a/lib/r3x/triggers/base.rb b/lib/r3x/triggers/base.rb new file mode 100644 index 00000000..acc9cb32 --- /dev/null +++ b/lib/r3x/triggers/base.rb @@ -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 diff --git a/lib/r3x/triggers/rss.rb b/lib/r3x/triggers/rss.rb new file mode 100644 index 00000000..a301e160 --- /dev/null +++ b/lib/r3x/triggers/rss.rb @@ -0,0 +1,32 @@ +module R3x + module Triggers + class Rss < Base + DEFAULT_EVERY = "every hour" + + def initialize(url:, every: DEFAULT_EVERY, **options) + super(:rss, url: url, every: every, **options) + end + + def url + options[:url] + end + + def every + options[:every] + end + + def validate! + if url.nil? || url.to_s.empty? + raise ArgumentError, "trigger :rss requires a 'url' option" + end + + Validators::Url.validate!(url, field_name: "url") + Validators::Cron.validate!(every, field_name: "every") + end + + def to_h + { type: :rss, url: url, every: every } + end + end + end +end diff --git a/lib/r3x/triggers/schedule.rb b/lib/r3x/triggers/schedule.rb new file mode 100644 index 00000000..067e3cbf --- /dev/null +++ b/lib/r3x/triggers/schedule.rb @@ -0,0 +1,25 @@ +module R3x + module Triggers + class Schedule < Base + def initialize(cron: nil, **options) + super(:schedule, cron: cron, **options) + end + + def cron + options[:cron] + end + + def validate! + if cron.nil? || cron.to_s.empty? + raise ArgumentError, "trigger :schedule requires a 'cron' option (e.g., cron: '0 13 * * *' or cron: 'every day at 13:00')" + end + + Validators::Cron.validate!(cron, field_name: "cron") + end + + def to_h + { type: :schedule, cron: cron } + end + end + end +end diff --git a/lib/r3x/validators/cron.rb b/lib/r3x/validators/cron.rb new file mode 100644 index 00000000..9772a5a5 --- /dev/null +++ b/lib/r3x/validators/cron.rb @@ -0,0 +1,18 @@ +require "fugit" + +module R3x + module Validators + class Cron + def self.validate!(value, field_name: "cron") + return if value.nil? || value.empty? + + parsed = Fugit.parse(value, multi: :fail) + unless parsed.is_a?(Fugit::Cron) + raise ArgumentError, "#{field_name}: '#{value}' is not a valid cron expression" + end + rescue ArgumentError => e + raise ArgumentError, "#{field_name}: '#{value}' is not a valid cron expression (#{e.message})" + end + end + end +end diff --git a/lib/r3x/validators/url.rb b/lib/r3x/validators/url.rb new file mode 100644 index 00000000..0a553e48 --- /dev/null +++ b/lib/r3x/validators/url.rb @@ -0,0 +1,16 @@ +module R3x + module Validators + class Url + def self.validate!(value, field_name: "url") + return if value.nil? || value.empty? + + uri = URI.parse(value) + unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + raise ArgumentError, "#{field_name}: '#{value}' is not a valid HTTP/HTTPS URL" + end + rescue URI::InvalidURIError => e + raise ArgumentError, "#{field_name}: '#{value}' is not a valid URL (#{e.message})" + end + end + end +end diff --git a/lib/r3x/workflow.rb b/lib/r3x/workflow.rb new file mode 100644 index 00000000..69832008 --- /dev/null +++ b/lib/r3x/workflow.rb @@ -0,0 +1,38 @@ +module R3x + class Workflow + class << self + def inherited(subclass) + subclass.instance_variable_set(:@_triggers, []) + end + + def workflow_key + name.demodulize.underscore + end + + def trigger(type, **options) + trigger_class = Triggers.resolve(type) + trigger_instance = trigger_class.new(**options) + + trigger_instance.validate! + @_triggers << trigger_instance + end + + def triggers + @_triggers ||= [] + @_triggers.dup + end + + def schedule_trigger + triggers.find { |t| t.type == :schedule } + end + + def rss_triggers + triggers.select { |t| t.type == :rss } + end + end + + def run(ctx) + raise NotImplementedError, "Workflow must implement #run(ctx)" + end + end +end diff --git a/lib/r3x/workflow_context.rb b/lib/r3x/workflow_context.rb index fccc0541..ca0cedd8 100644 --- a/lib/r3x/workflow_context.rb +++ b/lib/r3x/workflow_context.rb @@ -2,7 +2,10 @@ module R3x class WorkflowContext include R3x::Concerns::Logger - def initialize + attr_reader :triggered_by + + def initialize(triggered_by: nil) + @triggered_by = triggered_by || TriggeredBy.new(:manual) end def fetch_body(url) @@ -10,7 +13,7 @@ def fetch_body(url) end def rss_trigger - @rss_trigger ||= R3x::Triggers::Rss.new + @rss_trigger ||= R3x::Services::RssParser.new end def discord_output diff --git a/lib/r3x/workflow_registry.rb b/lib/r3x/workflow_registry.rb index dc5f08f0..f6204d6f 100644 --- a/lib/r3x/workflow_registry.rb +++ b/lib/r3x/workflow_registry.rb @@ -3,7 +3,7 @@ class WorkflowRegistry class << self def register(workflow_class) mutex.synchronize do - key = workflow_class.respond_to?(:workflow_key) ? workflow_class.workflow_key.to_s : workflow_class.name.demodulize.underscore + key = workflow_class.workflow_key.to_s registrations[key] = workflow_class end end @@ -13,7 +13,7 @@ def fetch(workflow_key) end def all - registrations.values.sort_by { |c| c.respond_to?(:workflow_key) ? c.workflow_key : c.name } + registrations.values.sort_by(&:workflow_key) end def reset! diff --git a/lib/tasks/r3x_workflows.rake b/lib/tasks/r3x_workflows.rake index ed704100..21bbe065 100644 --- a/lib/tasks/r3x_workflows.rake +++ b/lib/tasks/r3x_workflows.rake @@ -1,5 +1,49 @@ namespace :r3x do namespace :workflows do + desc "List all registered workflows with their triggers" + task list: :environment do + R3x::WorkflowPackLoader.load! + + workflows = R3x::WorkflowRegistry.all + + if workflows.empty? + puts "No workflows registered." + next + end + + puts "\nRegistered Workflows (#{workflows.size}):" + puts "=" * 60 + + workflows.each do |workflow_class| + key = workflow_class.workflow_key + class_name = workflow_class.name + + puts "\n#{key}" + puts " Class: #{class_name}" + + triggers = workflow_class.triggers + if triggers.empty? + puts " Triggers: none" + else + puts " Triggers:" + triggers.each do |trigger| + case trigger.type + when :schedule + puts " - schedule: #{trigger.cron}" + when :rss + puts " - rss: #{trigger.url}" + puts " every: #{trigger.every}" + else + puts " - #{trigger.type}: #{trigger.to_h}" + end + end + end + end + + puts "\n" + "=" * 60 + puts "Total: #{workflows.size} workflow(s)" + end + desc "Run all workflows or a specific one (r3x:workflows:run[workflow_key])" task :run, [ :workflow_key ] => :environment do |_task, args| R3x::WorkflowPackLoader.load! diff --git a/test/fixtures/workflows/test_workflow/workflow.rb b/test/fixtures/workflows/test_workflow/workflow.rb index 69ac6db6..8d557999 100644 --- a/test/fixtures/workflows/test_workflow/workflow.rb +++ b/test/fixtures/workflows/test_workflow/workflow.rb @@ -1,14 +1,6 @@ module Workflows - class TestWorkflow - class << self - def workflow_key - "test_workflow" - end - - def trigger_types - %w[manual schedule] - end - end + class TestWorkflow < R3x::Workflow + trigger :schedule, cron: "0 * * * *" def run(ctx) { diff --git a/test/jobs/r3x/run_workflow_job_test.rb b/test/jobs/r3x/run_workflow_job_test.rb new file mode 100644 index 00000000..340838e3 --- /dev/null +++ b/test/jobs/r3x/run_workflow_job_test.rb @@ -0,0 +1,84 @@ +require "test_helper" + +module R3x + class RunWorkflowJobTest < ActiveSupport::TestCase + setup do + @original_workflow_paths = ENV["R3X_WORKFLOW_PATHS"] + ENV["R3X_WORKFLOW_PATHS"] = Rails.root.join("test/fixtures/workflows").to_s + WorkflowPackLoader.load!(force: true) + end + + teardown do + ENV["R3X_WORKFLOW_PATHS"] = @original_workflow_paths + end + + test "performs workflow with manual trigger when no triggered_by provided" do + job = RunWorkflowJob.new + + # The test_workflow is already loaded from fixtures and has a simple run method + result = job.perform("test_workflow") + + assert_equal true, result["test"] + assert_equal "Test workflow executed successfully", result["message"] + end + + test "performs workflow with schedule trigger" do + job = RunWorkflowJob.new + + # Create a test workflow class that checks triggered_by + test_workflow_class = Class.new(R3x::Workflow) do + def self.name + "TestTriggeredBy" + end + + def run(ctx) + { + "triggered_by_type" => ctx.triggered_by.type.to_s, + "schedule?" => ctx.triggered_by.schedule?, + "manual?" => ctx.triggered_by.manual? + } + end + end + + WorkflowRegistry.register(test_workflow_class) + + result = job.perform("test_triggered_by", triggered_by: "schedule") + + assert_equal "schedule", result["triggered_by_type"] + assert result["schedule?"] + refute result["manual?"] + ensure + WorkflowRegistry.reset! + WorkflowPackLoader.load!(force: true) + end + + test "performs workflow with manual trigger explicitly" do + job = RunWorkflowJob.new + + test_workflow_class = Class.new(R3x::Workflow) do + def self.name + "TestManual" + end + + def run(ctx) + { + "triggered_by_type" => ctx.triggered_by.type.to_s, + "schedule?" => ctx.triggered_by.schedule?, + "manual?" => ctx.triggered_by.manual? + } + end + end + + WorkflowRegistry.register(test_workflow_class) + + result = job.perform("test_manual", triggered_by: "manual") + + assert_equal "manual", result["triggered_by_type"] + refute result["schedule?"] + assert result["manual?"] + ensure + WorkflowRegistry.reset! + WorkflowPackLoader.load!(force: true) + end + end +end diff --git a/test/lib/r3x/recurring_tasks_config_test.rb b/test/lib/r3x/recurring_tasks_config_test.rb new file mode 100644 index 00000000..3d1c6703 --- /dev/null +++ b/test/lib/r3x/recurring_tasks_config_test.rb @@ -0,0 +1,46 @@ +require "test_helper" +require "r3x/recurring_tasks_config" + +module R3x + class RecurringTasksConfigTest < ActiveSupport::TestCase + setup do + @original_workflow_paths = ENV["R3X_WORKFLOW_PATHS"] + ENV["R3X_WORKFLOW_PATHS"] = Rails.root.join("test/fixtures/workflows").to_s + WorkflowPackLoader.load!(force: true) + end + + teardown do + ENV["R3X_WORKFLOW_PATHS"] = @original_workflow_paths + end + + test "generates recurring tasks from workflow DSL" do + tasks = RecurringTasksConfig.to_h + + assert tasks.key?("test_workflow") + task = tasks["test_workflow"] + + assert_equal "R3x::RunWorkflowJob", task["class"] + assert_equal [ "test_workflow", { "triggered_by" => "schedule" } ], task["args"] + assert_equal "0 * * * *", task["schedule"] + assert_equal "default", task["queue"] + end + + test "only includes workflows with schedule triggers" do + # Create a workflow without schedule trigger + workflow_class = Class.new(R3x::Workflow) do + def self.name + "Workflows::NoSchedule" + end + end + + WorkflowRegistry.register(workflow_class) + + tasks = RecurringTasksConfig.to_h + refute tasks.key?("no_schedule") + + # Cleanup + WorkflowRegistry.reset! + WorkflowPackLoader.load!(force: true) + end + end +end diff --git a/test/lib/r3x/triggers/rss_test.rb b/test/lib/r3x/services/rss_parser_test.rb similarity index 76% rename from test/lib/r3x/triggers/rss_test.rb rename to test/lib/r3x/services/rss_parser_test.rb index b8f576bb..048c2964 100644 --- a/test/lib/r3x/triggers/rss_test.rb +++ b/test/lib/r3x/services/rss_parser_test.rb @@ -1,12 +1,12 @@ require "test_helper" module R3x - module Triggers - class RssTest < ActiveSupport::TestCase + module Services + class RssParserTest < ActiveSupport::TestCase test "parses rss feed into normalized item hashes" do body = File.read(Rails.root.join("test/fixtures/files/rss_test.xml")) - items = R3x::Triggers::Rss.new.parse(body, source_url: "https://example.com/rss") + items = R3x::Services::RssParser.new.parse(body, source_url: "https://example.com/rss") assert_equal 2, items.size assert_equal "https://example.com/article-1", items.first.fetch("url") diff --git a/test/lib/r3x/triggered_by_test.rb b/test/lib/r3x/triggered_by_test.rb new file mode 100644 index 00000000..d8d34bba --- /dev/null +++ b/test/lib/r3x/triggered_by_test.rb @@ -0,0 +1,53 @@ +require "test_helper" +require "r3x/triggered_by" + +module R3x + class TriggeredByTest < ActiveSupport::TestCase + test "schedule? returns true for schedule type" do + tb = TriggeredBy.new(:schedule) + assert tb.schedule? + refute tb.rss? + refute tb.manual? + end + + test "rss? returns true for rss type" do + tb = TriggeredBy.new(:rss) + refute tb.schedule? + assert tb.rss? + refute tb.manual? + end + + test "manual? returns true for manual type" do + tb = TriggeredBy.new(:manual) + refute tb.schedule? + refute tb.rss? + assert tb.manual? + end + + test "type returns symbol" do + tb = TriggeredBy.new("schedule") + assert_equal :schedule, tb.type + end + + test "equality with symbol" do + tb = TriggeredBy.new(:schedule) + assert tb == :schedule + refute tb == :rss + end + + test "equality with another TriggeredBy" do + tb1 = TriggeredBy.new(:schedule) + tb2 = TriggeredBy.new(:schedule) + tb3 = TriggeredBy.new(:rss) + assert tb1 == tb2 + refute tb1 == tb3 + end + + test "inequality with other types" do + tb = TriggeredBy.new(:schedule) + refute tb == "schedule" + refute tb.nil? + refute tb == 123 + end + end +end diff --git a/test/lib/r3x/validators/cron_test.rb b/test/lib/r3x/validators/cron_test.rb new file mode 100644 index 00000000..036539ee --- /dev/null +++ b/test/lib/r3x/validators/cron_test.rb @@ -0,0 +1,53 @@ +require "test_helper" +require "r3x/validators/cron" + +module R3x + module Validators + class CronTest < ActiveSupport::TestCase + test "accepts standard cron expression" do + assert_nothing_raised do + Cron.validate!("0 13 * * *") + end + end + + test "accepts human readable cron via fugit" do + assert_nothing_raised do + Cron.validate!("every day at 13:00") + end + end + + test "accepts various human readable formats" do + assert_nothing_raised do + Cron.validate!("every hour") + Cron.validate!("every 15 minutes") + Cron.validate!("every weekday at 9am") + end + end + + test "rejects invalid cron" do + assert_raises(ArgumentError) do + Cron.validate!("invalid cron") + end + end + + test "rejects empty cron" do + assert_nothing_raised do + Cron.validate!("") + end + end + + test "rejects nil cron" do + assert_nothing_raised do + Cron.validate!(nil) + end + end + + test "uses custom field name in error message" do + error = assert_raises(ArgumentError) do + Cron.validate!("not valid", field_name: "every") + end + assert_match(/every:/, error.message) + end + end + end +end diff --git a/test/lib/r3x/validators/url_test.rb b/test/lib/r3x/validators/url_test.rb new file mode 100644 index 00000000..694a5c85 --- /dev/null +++ b/test/lib/r3x/validators/url_test.rb @@ -0,0 +1,57 @@ +require "test_helper" +require "r3x/validators/url" + +module R3x + module Validators + class UrlTest < ActiveSupport::TestCase + test "accepts valid HTTP URL" do + assert_nothing_raised do + Url.validate!("http://example.com/rss") + end + end + + test "accepts valid HTTPS URL" do + assert_nothing_raised do + Url.validate!("https://example.com/rss") + end + end + + test "accepts URL with path and query" do + assert_nothing_raised do + Url.validate!("https://example.com/feed.xml?format=rss") + end + end + + test "rejects invalid URL" do + assert_raises(ArgumentError) do + Url.validate!("not a url") + end + end + + test "rejects FTP URL" do + assert_raises(ArgumentError) do + Url.validate!("ftp://example.com/file") + end + end + + test "rejects empty URL" do + assert_nothing_raised do + Url.validate!("") + end + end + + test "rejects nil URL" do + assert_nothing_raised do + Url.validate!(nil) + end + end + + test "uses custom field name in error message" do + error = assert_raises(ArgumentError) do + Url.validate!("invalid", field_name: "feed_url") + end + assert_match(/feed_url:/, error.message) + end + end + end +end diff --git a/test/lib/r3x/workflow_context_test.rb b/test/lib/r3x/workflow_context_test.rb new file mode 100644 index 00000000..26568aa8 --- /dev/null +++ b/test/lib/r3x/workflow_context_test.rb @@ -0,0 +1,32 @@ +require "test_helper" +require "r3x/workflow_context" +require "r3x/triggered_by" + +module R3x + class WorkflowContextTest < ActiveSupport::TestCase + test "defaults to manual trigger when no triggered_by provided" do + ctx = WorkflowContext.new + assert ctx.triggered_by.manual? + end + + test "accepts TriggeredBy object" do + triggered_by = TriggeredBy.new(:schedule) + ctx = WorkflowContext.new(triggered_by: triggered_by) + assert ctx.triggered_by.schedule? + end + + test "schedule trigger detection" do + ctx = WorkflowContext.new(triggered_by: TriggeredBy.new(:schedule)) + assert ctx.triggered_by.schedule? + refute ctx.triggered_by.rss? + refute ctx.triggered_by.manual? + end + + test "rss trigger detection" do + ctx = WorkflowContext.new(triggered_by: TriggeredBy.new(:rss)) + refute ctx.triggered_by.schedule? + assert ctx.triggered_by.rss? + refute ctx.triggered_by.manual? + end + end +end diff --git a/test/lib/r3x/workflow_pack_loader_test.rb b/test/lib/r3x/workflow_pack_loader_test.rb index f448bf75..1798945d 100644 --- a/test/lib/r3x/workflow_pack_loader_test.rb +++ b/test/lib/r3x/workflow_pack_loader_test.rb @@ -17,7 +17,11 @@ class WorkflowPackLoaderTest < ActiveSupport::TestCase assert_equal Workflows::TestWorkflow, workflow_class assert_equal "test_workflow", workflow_class.workflow_key - assert_equal %w[manual schedule], workflow_class.trigger_types + + schedule = workflow_class.schedule_trigger + assert schedule + assert_equal :schedule, schedule.type + assert_equal "0 * * * *", schedule.cron end test "raises KeyError for unknown workflow" do diff --git a/test/lib/r3x/workflow_test.rb b/test/lib/r3x/workflow_test.rb new file mode 100644 index 00000000..d11b1c14 --- /dev/null +++ b/test/lib/r3x/workflow_test.rb @@ -0,0 +1,193 @@ +require "test_helper" +require "r3x/workflow" + +module R3x + class WorkflowTest < ActiveSupport::TestCase + test "workflow_key is derived from class name by convention" do + klass = Class.new(R3x::Workflow) do + def self.name + "Workflows::MyAwesomeWorkflow" + end + end + + assert_equal "my_awesome_workflow", klass.workflow_key + end + + test "workflow_key works for single word class" do + klass = Class.new(R3x::Workflow) do + def self.name + "Workflows::Test" + end + end + + assert_equal "test", klass.workflow_key + end + + test "trigger :schedule requires cron option" do + assert_raises(ArgumentError) do + Class.new(R3x::Workflow) do + def self.name + "Test" + end + trigger :schedule + end + end + end + + test "trigger :schedule accepts valid cron expression" do + klass = Class.new(R3x::Workflow) do + def self.name + "Test" + end + trigger :schedule, cron: "0 13 * * *" + end + + schedule = klass.schedule_trigger + assert schedule + assert_equal :schedule, schedule.type + assert_equal "0 13 * * *", schedule.cron + end + + test "trigger :schedule accepts human readable cron" do + klass = Class.new(R3x::Workflow) do + def self.name + "Test" + end + trigger :schedule, cron: "every day at 13:00" + end + + schedule = klass.schedule_trigger + assert schedule + assert_equal "every day at 13:00", schedule.cron + end + + test "trigger :schedule rejects invalid cron" do + assert_raises(ArgumentError) do + Class.new(R3x::Workflow) do + def self.name + "Test" + end + trigger :schedule, cron: "invalid cron syntax" + end + end + end + + test "trigger :rss requires url option" do + assert_raises(ArgumentError) do + Class.new(R3x::Workflow) do + def self.name + "Test" + end + trigger :rss + end + end + end + + test "trigger :rss with url and default every" do + klass = Class.new(R3x::Workflow) do + def self.name + "Test" + end + trigger :rss, url: "https://example.com/rss" + end + + rss = klass.rss_triggers.first + assert rss + assert_equal :rss, rss.type + assert_equal "https://example.com/rss", rss.url + assert_equal "every hour", rss.every + end + + test "trigger :rss with custom every" do + klass = Class.new(R3x::Workflow) do + def self.name + "Test" + end + trigger :rss, url: "https://example.com/rss", every: "every 15 minutes" + end + + rss = klass.rss_triggers.first + assert rss + assert_equal "every 15 minutes", rss.every + end + + test "trigger :rss validates every is valid cron" do + assert_raises(ArgumentError) do + Class.new(R3x::Workflow) do + def self.name + "Test" + end + trigger :rss, url: "https://example.com/rss", every: "not valid" + end + end + end + + test "unknown trigger type raises error" do + assert_raises(ArgumentError) do + Class.new(R3x::Workflow) do + def self.name + "Test" + end + trigger :unknown + end + end + end + + test "triggers returns all registered triggers" do + klass = Class.new(R3x::Workflow) do + def self.name + "Test" + end + trigger :schedule, cron: "0 13 * * *" + trigger :rss, url: "https://example.com/rss" + end + + triggers = klass.triggers + assert_equal 2, triggers.size + assert_equal [ :schedule, :rss ], triggers.map(&:type) + end + + test "trigger :schedule rejects blank cron" do + assert_raises(ArgumentError) do + Class.new(R3x::Workflow) do + def self.name + "Test" + end + trigger :schedule, cron: "" + end + end + end + + test "trigger :rss rejects blank url" do + assert_raises(ArgumentError) do + Class.new(R3x::Workflow) do + def self.name + "Test" + end + trigger :rss, url: "" + end + end + end + + test "supported_types returns list of available trigger files" do + types = R3x::Triggers.supported_types + assert_includes types, :rss + assert_includes types, :schedule + refute_includes types, :base + end + + test "unknown trigger type raises error with dynamic supported types list" do + error = assert_raises(ArgumentError) do + Class.new(R3x::Workflow) do + def self.name + "Test" + end + trigger :nonexistent + end + end + + assert_match(/Unknown trigger type: nonexistent/, error.message) + assert_match(/Supported types:.*:rss.*:schedule/, error.message) + end + end +end