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
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ This Rails app uses a small set of preferred libraries for common integration wo
- `lib/r3x/dsl/`: shared DSL infrastructure, especially validation concerns and configuration errors used by workflow-declared objects.
- `lib/r3x/trigger_manager.rb` + `lib/r3x/trigger_manager/`: trigger infrastructure — `R3x::TriggerManager::Collection` (manages workflow triggers as a hash keyed by `unique_key`) and `R3x::TriggerManager::Execution` (wraps a trigger for runtime use).
- `app/lib/r3x/`: runtime support code such as outputs, client wrappers, and shared concerns.
- `app/lib/r3x/client/google/credentials.rb`: shared Google credentials loader used by Gmail and Google Sheets integrations.
- `app/lib/r3x/client/google/gmail.rb`: Gmail API client used by `R3x::Outputs::Gmail`.
- `R3x::Client::Google` is a project namespace; when referencing the third-party Google gem namespace, use `::Google` to avoid constant collisions.
- `app/jobs/r3x/`: job entrypoints, especially `R3x::RunWorkflowJob`, which resolves and executes workflows, and `R3x::ChangeDetectionJob`, which evaluates change-detecting triggers before enqueueing workflow runs.
- `app/models/r3x/`: runtime support models such as `R3x::TriggerState` for per-trigger change-detection state.
- `workflows/`: user workflow packs. These are not the framework itself; they are loaded by the framework.
Expand All @@ -40,6 +43,18 @@ This Rails app uses a small set of preferred libraries for common integration wo

Use `bin/workflow` (preferred) or rake tasks to interact with workflows from the command line. Both load all workflow packs via `PackLoader.load!` and query `Registry`.

### Output safety

- New workflow code that can cause external side effects (email, API writes, webhooks, state changes outside R3x) should default to `dry_run: true` or equivalent safe mode.
- Only switch to real delivery with an explicit opt-in in the workflow or script, e.g. `dry_run: false`.
- If a client can be destructive or noisy, prefer a boolean `dry_run` flag over an implicit ENV-based mode switch.
- When a client is used from app/runtime code, resolve the default through `R3x::Policy.dry_run_for(:key, dry_run)`: development and test should be dry-run by default, production should default to real delivery unless the caller explicitly opts into `dry_run: true`.
- `R3x::Policy` may also honor per-feature overrides like `R3X_GMAIL_DRY_RUN` and a global `R3X_DRY_RUN` if we need to widen or narrow the policy later.
- For integration credentials, prefer passing `*_env` references like `credentials_env:` or `api_key_env:` instead of raw secrets or parsed credential hashes. Resolve the secret lazily inside the client/output so dry-run paths can avoid loading credentials when they do not need them.
- When integrating a third-party API, put the actual API logic in a dedicated client object under `app/lib/r3x/client/<provider>/...` and keep outputs as thin policy/delivery wrappers.
- When adding a new output/client with a real delivery path, include a dry-run path first and test that it does not call the external service.
- Scratchpad scripts should also default to dry-run unless the user explicitly asks for real delivery.

### `bin/workflow` — CLI entrypoint

```
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ gem "googleauth"
gem "google-apis-calendar_v3"
gem "google-apis-gmail_v1"
gem "google-apis-sheets_v4"
gem "mail"

# Use the database-backed adapters for Rails.cache and Active Job
gem "solid_cache"
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ DEPENDENCIES
google-cloud-translate
googleauth
highline
mail
mission_control-jobs
multi_json
nokogiri
Expand Down
13 changes: 13 additions & 0 deletions app/lib/r3x/client/google/credentials.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module R3x
module Client
module Google
module Credentials
def self.from_env(credentials_env)
MultiJson.load(R3x::Env.secure_fetch(credentials_env, prefix: "GOOGLE_CREDENTIALS_"))
end
end
end
end
end
48 changes: 48 additions & 0 deletions app/lib/r3x/client/google/gmail.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

module R3x
module Client
module Google
class Gmail
include R3x::Concerns::Logger

def initialize(credentials_env:)
@credentials_env = credentials_env
end

def deliver(to:, subject:, body:)
result = build_service.send_user_message(
"me",
::Google::Apis::GmailV1::Message.new(raw: raw_message(to: to, subject: subject, body: body))
)

{
"mode" => "real",
"message_id" => result.id
}
end

private

attr_reader :credentials_env

def build_service
::Google::Apis::GmailV1::GmailService.new.tap do |service|
service.authorization = R3x::Client::GoogleAuth.from_json(
R3x::Client::Google::Credentials.from_env(credentials_env),
scope: ::Google::Apis::GmailV1::AUTH_GMAIL_SEND
)
end
end

def raw_message(to:, subject:, body:)
Mail.new.tap do |mail|
mail.to = to
mail.subject = subject
mail.body = body
end.to_s
end
end
end
end
end
16 changes: 8 additions & 8 deletions app/lib/r3x/client/google_auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ module R3x
module Client
module GoogleAuth
SCOPE_ALIASES = {
"gmail.readonly" => Google::Apis::GmailV1::AUTH_GMAIL_READONLY,
"gmail.send" => Google::Apis::GmailV1::AUTH_GMAIL_SEND,
"gmail.compose" => Google::Apis::GmailV1::AUTH_GMAIL_COMPOSE,
"gmail.modify" => Google::Apis::GmailV1::AUTH_GMAIL_MODIFY,
"sheets.readonly" => Google::Apis::SheetsV4::AUTH_SPREADSHEETS_READONLY,
"sheets" => Google::Apis::SheetsV4::AUTH_SPREADSHEETS,
"calendar.readonly" => Google::Apis::CalendarV3::AUTH_CALENDAR_READONLY,
"calendar" => Google::Apis::CalendarV3::AUTH_CALENDAR
"gmail.readonly" => ::Google::Apis::GmailV1::AUTH_GMAIL_READONLY,
"gmail.send" => ::Google::Apis::GmailV1::AUTH_GMAIL_SEND,
"gmail.compose" => ::Google::Apis::GmailV1::AUTH_GMAIL_COMPOSE,
"gmail.modify" => ::Google::Apis::GmailV1::AUTH_GMAIL_MODIFY,
"sheets.readonly" => ::Google::Apis::SheetsV4::AUTH_SPREADSHEETS_READONLY,
"sheets" => ::Google::Apis::SheetsV4::AUTH_SPREADSHEETS,
"calendar.readonly" => ::Google::Apis::CalendarV3::AUTH_CALENDAR_READONLY,
"calendar" => ::Google::Apis::CalendarV3::AUTH_CALENDAR
}.freeze

def self.from_json(parsed_json, scope:)
Expand Down
12 changes: 6 additions & 6 deletions app/lib/r3x/client/google_sheets.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
module R3x
module Client
class GoogleSheets
def initialize(spreadsheet_id:, credentials:)
def initialize(spreadsheet_id:, credentials_env:)
@spreadsheet_id = spreadsheet_id
@credentials = credentials
@credentials_env = credentials_env
@service = build_service
end

Expand All @@ -25,13 +25,13 @@ def read_rows(range:, headers: true)

private

attr_reader :spreadsheet_id, :credentials, :service
attr_reader :spreadsheet_id, :credentials_env, :service

def build_service
service = Google::Apis::SheetsV4::SheetsService.new
service = ::Google::Apis::SheetsV4::SheetsService.new
service.authorization = R3x::Client::GoogleAuth.from_json(
credentials,
scope: Google::Apis::SheetsV4::AUTH_SPREADSHEETS_READONLY
R3x::Client::Google::Credentials.from_env(credentials_env),
scope: ::Google::Apis::SheetsV4::AUTH_SPREADSHEETS_READONLY
)
service
end
Expand Down
16 changes: 12 additions & 4 deletions app/lib/r3x/outputs/discord.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@ module Outputs
class Discord
include R3x::Concerns::Logger

def initialize
def initialize(dry_run: nil)
@dry_run = R3x::Policy.dry_run_for(:discord, dry_run)
@webhook_url = ENV["R3X_DISCORD_WEBHOOK_URL"]
end

def deliver(content:)
payload = { "content" => content }

raise ArgumentError, "Missing Discord webhook URL" if webhook_url.blank?
R3x::Client::DiscordWebhook.new(webhook_url: webhook_url).deliver(content: content)
if dry_run
logger.info("Discord [DRY-RUN] #{content}")
else
raise ArgumentError, "Missing Discord webhook URL" if webhook_url.blank?

R3x::Client::DiscordWebhook.new(webhook_url: webhook_url).deliver(content: content)
end

payload.merge("delivery_mode" => dry_run ? "dry-run" : "real")
end

private

attr_reader :webhook_url
attr_reader :dry_run, :webhook_url
end
end
end
35 changes: 35 additions & 0 deletions app/lib/r3x/outputs/gmail.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

module R3x
module Outputs
class Gmail
include R3x::Concerns::Logger

def initialize(credentials_env:, dry_run: nil)
@credentials_env = credentials_env
@dry_run = R3x::Policy.dry_run_for(:gmail, dry_run)
end

def deliver(to:, subject:, body:)
if dry_run
deliver_dry_run(to: to, subject: subject, body: body)
else
R3x::Client::Google::Gmail.new(credentials_env: credentials_env).deliver(
to: to,
subject: subject,
body: body
)
end
end

private

attr_reader :credentials_env, :dry_run

def deliver_dry_run(to:, subject:, body:)
logger.info("Gmail [DRY-RUN] to=#{to} subject=#{subject}\n#{body}")
{ "mode" => "dry-run" }
end
end
end
end
52 changes: 52 additions & 0 deletions lib/r3x/policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

module R3x
class Policy
class << self
def dry_run_for(key = nil, dry_run = nil)
dry_run.nil? ? default_dry_run_for(key) : dry_run
end

def real_delivery_for?(key = nil, dry_run = nil)
!dry_run_for(key, dry_run)
end

def default_dry_run_for(key = nil)
override = env_override(key)
return override unless override.nil?

Rails.env.development? || Rails.env.test?
end

private

def env_override(key)
return if key.blank?

[ specific_dry_run_env_key(key), "R3X_DRY_RUN" ].each do |env_key|
value = R3x::Env.fetch(env_key)
next if value.nil?

return parse_boolean(value)
end

nil
end

def specific_dry_run_env_key(key)
"R3X_#{key.to_s.upcase}_DRY_RUN"
end

def parse_boolean(value)
case value.to_s.downcase
when "1", "true", "yes", "on"
true
when "0", "false", "no", "off"
false
else
raise ArgumentError, "Invalid boolean for dry run: #{value.inspect}"
end
end
end
end
end
9 changes: 8 additions & 1 deletion lib/r3x/workflow/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,14 @@ def llm(api_key_env:)
def google_sheets(spreadsheet_id:, credentials_env:)
R3x::Client::GoogleSheets.new(
spreadsheet_id: spreadsheet_id,
credentials: MultiJson.load(R3x::Env.secure_fetch(credentials_env, prefix: "GOOGLE_CREDENTIALS_"))
credentials_env: credentials_env
)
end

def gmail(credentials_env:, dry_run: nil)
R3x::Outputs::Gmail.new(
credentials_env: credentials_env,
dry_run: dry_run
)
end

Expand Down
28 changes: 28 additions & 0 deletions test/lib/r3x/client/google/credentials_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
require "test_helper"

module R3x
module Client
module Google
class CredentialsTest < ActiveSupport::TestCase
test "loads credentials from env" do
ENV["GOOGLE_CREDENTIALS_TEST_APP"] = MultiJson.dump(
client_id: "client-id",
client_secret: "client-secret",
refresh_token: "refresh-token"
)

assert_equal(
{
"client_id" => "client-id",
"client_secret" => "client-secret",
"refresh_token" => "refresh-token"
},
Credentials.from_env("GOOGLE_CREDENTIALS_TEST_APP")
)
ensure
ENV.delete("GOOGLE_CREDENTIALS_TEST_APP")
end
end
end
end
end
Loading