Files
sure/app/controllers/rules_controller.rb
T
Juan José MataandClaude Opus 5 da483746e2 Add comprehensive debug logging to AI cache reset job (#3046)
* Trace "Reset AI cache" runs in the debug log

The /rules "Reset AI cache" button fired a background job whose only
output was Rails.logger, so there was no way to tell from the app whether
a reset ran, partially failed, or never started.

Every stage now writes to DebugLogEntry under the new "ai_cache_reset"
category, so a whole run is filterable in /settings/debug:

- info when the request is enqueued from the rules page, and info again
  when the job starts (a request with no matching start means the job
  never reached a worker)
- error when a scope fails outright, or when the enqueue itself fails
- warn (capped at 5 per scope) for individual records that could not be
  cleared, plus warn when the job is handed no family
- info on completion with the number of AI cache entries removed, broken
  down by scope, with failures and skipped records in the metadata

The completion count needed fixing to be worth reporting: the class-level
Enrichable.clear_ai_cache counted records visited, not cache entries
removed, so it reported every transaction in the family regardless of
whether anything was cleared. It now sums the enrichments actually
deleted, and takes an optional block so a single unclearable record
warns and is counted instead of aborting the sweep and discarding the
tally of everything already cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWwaCxjhSxBpvvgNLmCw67

* Treat a false perform_later result as an enqueue failure

perform_later turns an ActiveJob::EnqueueError — or an enqueue aborted by
a callback — into a false return rather than raising it, so the previous
rescue-only check missed those cases entirely: the controller logged the
reset as requested and redirected with a success notice while nothing had
been queued, which is exactly the blind spot this branch set out to close.

Branch on the return value and raise the job's own enqueue_error when it
carries one, so both failure modes route through the same error entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWwaCxjhSxBpvvgNLmCw67

* Cover the yielded enqueue_error path and assert the job argument

The false-return test stubs perform_later without yielding, so it only
exercised the fallback error. The branch that re-raises the job's own
enqueue_error — the one that carries the adapter's underlying cause into
the debug entry, which is the point of surfacing it at all — had no
coverage. Add a test that yields a job carrying an EnqueueError and
asserts the cause reaches both the raised error and the entry metadata.

Also assert the family is what gets enqueued, in all three tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWwaCxjhSxBpvvgNLmCw67

* Scope the enqueue rescue to the enqueue

The rescue reports "could not be enqueued", but it also covered the
request log that runs after the job is safely queued. That was harmless
in practice — DebugLogEntry.capture rescues internally and returns nil,
so it cannot raise — but the guarantee rested on the internals of a
different class rather than on the shape of this method.

Split the enqueue into its own method so the rescue covers only what it
reports on. Nothing after a successful enqueue can now be recorded as an
enqueue failure and retried, regardless of what those later steps call.

No behavior change on any of the four paths already covered by tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWwaCxjhSxBpvvgNLmCw67

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-16 01:01:54 +02:00

199 lines
6.3 KiB
Ruby

class RulesController < ApplicationController
include StreamExtensions
before_action :set_rule, only: [ :edit, :update, :destroy, :apply, :confirm ]
def index
@sort_by = params[:sort_by] || "name"
@direction = params[:direction] || "asc"
allowed_columns = [ "name", "updated_at" ]
@sort_by = "name" unless allowed_columns.include?(@sort_by)
@direction = "asc" unless [ "asc", "desc" ].include?(@direction)
@rules = Current.family.rules.includes(conditions: :sub_conditions).order(@sort_by => @direction)
# Fetch recent rule runs with pagination
recent_runs_scope = RuleRun
.joins(:rule)
.where(rules: { family_id: Current.family.id })
.recent
.includes(:rule)
@pagy, @recent_runs = pagy(recent_runs_scope, limit: safe_per_page, page_param: :runs_page)
render layout: "settings"
end
def new
@rule = Current.family.rules.build(
resource_type: params[:resource_type] || "transaction",
)
if params[:name].present?
@rule.name = params[:name]
@rule.conditions.build(
condition_type: "transaction_name",
operator: "like",
value: params[:name]
)
end
if params[:action_type].present? && params[:action_value].present?
@rule.actions.build(
action_type: params[:action_type],
value: params[:action_value]
)
end
end
def create
@rule = Current.family.rules.build(rule_params)
if @rule.save
redirect_to confirm_rule_path(@rule, reload_on_close: true)
else
render :new, status: :unprocessable_entity
end
end
def apply
@rule.update!(active: true)
@rule.apply_later(ignore_attribute_locks: true)
redirect_back_or_to rules_path, notice: "#{@rule.resource_type.humanize} rule activated"
end
def confirm
# Compute provider, model, and cost estimation for auto-categorize actions
if @rule.actions.any? { |a| a.action_type == "auto_categorize" }
# Use the same provider determination logic as Family::AutoCategorizer
llm_provider = Provider::Registry.get_provider(:openai)
if llm_provider
@selected_model = Provider::Openai.effective_model
@estimated_cost = LlmUsage.estimate_auto_categorize_cost(
transaction_count: @rule.affected_resource_count,
category_count: @rule.family.categories.count,
model: @selected_model
)
end
end
end
def edit
end
def update
if @rule.update(rule_params)
respond_to do |format|
format.html { redirect_back_or_to rules_path, notice: t(".success") }
format.turbo_stream { stream_redirect_back_or_to rules_path, notice: t(".success") }
end
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@rule.destroy
redirect_to rules_path, notice: t(".success")
end
def destroy_all
Current.family.rules.destroy_all
redirect_to rules_path, notice: t(".success")
end
def confirm_all
@rules = Current.family.rules
@total_affected_count = Rule.total_affected_resource_count(@rules)
# Compute AI cost estimation if any rule has auto_categorize action
if @rules.any? { |r| r.actions.any? { |a| a.action_type == "auto_categorize" } }
llm_provider = Provider::Registry.get_provider(:openai)
if llm_provider
@selected_model = Provider::Openai.effective_model
@estimated_cost = LlmUsage.estimate_auto_categorize_cost(
transaction_count: @total_affected_count,
category_count: Current.family.categories.count,
model: @selected_model
)
end
end
end
def apply_all
ApplyAllRulesJob.perform_later(Current.family)
redirect_back_or_to rules_path, notice: t("rules.apply_all.success")
end
def clear_ai_cache
enqueue_ai_cache_reset
redirect_to rules_path, notice: t("rules.clear_ai_cache.success")
end
private
# The reset itself happens in a background job, so an enqueue that never
# lands looks exactly like a job that ran and found nothing. Logging the
# request separately from the job's own "started" entry tells those apart.
def enqueue_ai_cache_reset
perform_ai_cache_reset_later
DebugLogEntry.capture(
category: ClearAiCacheJob::DEBUG_CATEGORY,
level: "info",
message: "AI cache reset requested from the rules page",
source: self.class.name,
family: Current.family,
user: Current.user
)
end
# Split out so the rescue below covers only the enqueue it reports on.
# Anything that runs after the job is safely queued — the request log above,
# the redirect — is then structurally incapable of being recorded as an
# enqueue failure and retried, without that resting on the internals of
# whatever those later steps happen to call.
def perform_ai_cache_reset_later
attempted_job = nil
enqueued = ClearAiCacheJob.perform_later(Current.family) { |job| attempted_job = job }
# perform_later turns an ActiveJob::EnqueueError — or an enqueue aborted by
# a callback — into a false return rather than raising it, so the return
# value is the only signal that the reset never reached the queue. The
# yielded job carries the underlying error when there was one.
return if enqueued
raise attempted_job&.enqueue_error || ActiveJob::EnqueueError.new("ClearAiCacheJob was not enqueued")
rescue => e
DebugLogEntry.capture(
category: ClearAiCacheJob::DEBUG_CATEGORY,
level: "error",
message: "AI cache reset could not be enqueued: #{e.class}: #{e.message}",
source: self.class.name,
family: Current.family,
user: Current.user,
metadata: { error_class: e.class.name, error_message: e.message }
)
raise
end
def set_rule
@rule = Current.family.rules.find(params[:id])
end
def rule_params
params.require(:rule).permit(
:resource_type, :effective_date, :active, :name,
conditions_attributes: [
:id, :condition_type, :operator, :value, :_destroy,
sub_conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy ]
],
actions_attributes: [
:id, :action_type, :value, :_destroy
]
)
end
end