From ed7fe33d80460488ebde3e0a9abd025aeb38fe48 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Mon, 25 May 2026 17:19:32 -0400 Subject: [PATCH 1/2] feat: failed job detail page with inline argument editor and retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add show action and view to FailedJobsController; FailedJobs::ArgumentsController handles PATCH /failed_jobs/:id/arguments — parses the submitted JSON, updates the job arguments, and retries. Invalid JSON redirects back with an alert. Job class names in the failed jobs list are now links to the detail page. Co-Authored-By: Claude Sonnet 4.6 --- .../solid_stack_web/_09_detail.css | 14 +++++ .../failed_jobs/arguments_controller.rb | 17 ++++++ .../solid_stack_web/failed_jobs_controller.rb | 7 +++ .../failed_jobs/index.html.erb | 2 +- .../solid_stack_web/failed_jobs/show.html.erb | 58 ++++++++++++++++++ config/routes.rb | 3 +- .../solid_stack_web/failed_jobs_spec.rb | 60 +++++++++++++++++++ 7 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 app/controllers/solid_stack_web/failed_jobs/arguments_controller.rb create mode 100644 app/views/solid_stack_web/failed_jobs/show.html.erb diff --git a/app/assets/stylesheets/solid_stack_web/_09_detail.css b/app/assets/stylesheets/solid_stack_web/_09_detail.css index e897dda..dda42a3 100644 --- a/app/assets/stylesheets/solid_stack_web/_09_detail.css +++ b/app/assets/stylesheets/solid_stack_web/_09_detail.css @@ -68,4 +68,18 @@ word-break: break-word; max-height: 400px; overflow-y: auto; +} + +.sqw-code-input { + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem; + width: 100%; + resize: vertical; + color: var(--text); + line-height: 1.5; + box-sizing: border-box; } \ No newline at end of file diff --git a/app/controllers/solid_stack_web/failed_jobs/arguments_controller.rb b/app/controllers/solid_stack_web/failed_jobs/arguments_controller.rb new file mode 100644 index 0000000..56d0851 --- /dev/null +++ b/app/controllers/solid_stack_web/failed_jobs/arguments_controller.rb @@ -0,0 +1,17 @@ +module SolidStackWeb + module FailedJobs + class ArgumentsController < ApplicationController + def update + @execution = SolidQueue::FailedExecution.includes(:job).find(params[:failed_job_id]) + new_arguments = JSON.parse(params[:arguments]) + @execution.job.update!(arguments: new_arguments) + @execution.retry + redirect_to failed_jobs_path, notice: "Arguments updated and job queued for retry." + rescue JSON::ParserError + redirect_to failed_job_path(@execution), alert: "Invalid JSON — arguments were not saved." + rescue => e + redirect_to failed_jobs_path, alert: "Could not update job: #{e.message}" + end + end + end +end diff --git a/app/controllers/solid_stack_web/failed_jobs_controller.rb b/app/controllers/solid_stack_web/failed_jobs_controller.rb index 1982a86..36d7015 100644 --- a/app/controllers/solid_stack_web/failed_jobs_controller.rb +++ b/app/controllers/solid_stack_web/failed_jobs_controller.rb @@ -14,6 +14,13 @@ def index end end + def show + @execution = ::SolidQueue::FailedExecution.includes(:job).find(params[:id]) + @arguments = JSON.pretty_generate(@execution.job.arguments) if @execution.job.arguments.present? + rescue JSON::GeneratorError + @arguments = @execution.job.arguments.to_s + end + def destroy @execution = ::SolidQueue::FailedExecution.find(params[:id]) @execution.job.destroy! diff --git a/app/views/solid_stack_web/failed_jobs/index.html.erb b/app/views/solid_stack_web/failed_jobs/index.html.erb index 2b77d0e..74bbbd4 100644 --- a/app/views/solid_stack_web/failed_jobs/index.html.erb +++ b/app/views/solid_stack_web/failed_jobs/index.html.erb @@ -47,7 +47,7 @@ aria-label="Select <%= execution.job.class_name %>" data-selection-target="checkbox" data-action="change->selection#toggle"> - <%= execution.job.class_name %> + <%= link_to execution.job.class_name, failed_job_path(execution) %> <%= execution.job.queue_name %> <%= execution.exception_class %> <%= execution.created_at.strftime("%b %d %H:%M") %> diff --git a/app/views/solid_stack_web/failed_jobs/show.html.erb b/app/views/solid_stack_web/failed_jobs/show.html.erb new file mode 100644 index 0000000..744925b --- /dev/null +++ b/app/views/solid_stack_web/failed_jobs/show.html.erb @@ -0,0 +1,58 @@ +
+
+
+ <%= link_to "Failed Jobs", failed_jobs_path %> › Detail +
+

<%= @execution.job.class_name %>

+
+ +
+ <%= button_to "Retry", retry_failed_job_path(@execution), + method: :post, class: "sqw-btn sqw-btn--sm" %> + <%= button_to "Discard", failed_job_path(@execution), + method: :delete, class: "sqw-btn sqw-btn--danger", + data: { turbo_confirm: "Discard this job?" } %> +
+
+ +
+
+

Details

+
+
Queue
+
<%= @execution.job.queue_name %>
+ +
Priority
+
<%= @execution.job.priority %>
+ +
Active Job ID
+
<%= @execution.job.active_job_id.presence || "—" %>
+ +
Failed At
+
<%= @execution.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %>
+ +
Error
+
<%= @execution.exception_class %>
+ +
Message
+
<%= @execution.message %>
+
+
+ +
+

Backtrace

+
<%= Array(@execution.backtrace).first(10).join("\n").presence || "—" %>
+
+
+ +
+

Arguments

+ <%= form_with url: failed_job_arguments_path(@execution), method: :patch do |f| %> + <%= f.text_area :arguments, value: @arguments, rows: 12, + class: "sqw-code-input", aria: { label: "Job arguments JSON" }, + spellcheck: false %> +
+ <%= f.submit "Update & Retry".html_safe, class: "sqw-btn sqw-btn--sm" %> +
+ <% end %> +
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 7317639..66336fb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,8 +10,9 @@ end end - resources :failed_jobs, only: [:index, :destroy] do + resources :failed_jobs, only: [:index, :show, :destroy] do member { post :retry } + resource :arguments, only: [:update], controller: "failed_jobs/arguments" end resources :queues, only: [:index] do diff --git a/spec/requests/solid_stack_web/failed_jobs_spec.rb b/spec/requests/solid_stack_web/failed_jobs_spec.rb index 9aa3f5e..7dd7690 100644 --- a/spec/requests/solid_stack_web/failed_jobs_spec.rb +++ b/spec/requests/solid_stack_web/failed_jobs_spec.rb @@ -106,6 +106,66 @@ def create_failed(class_name: "FailingJob", queue_name: "default") end end + describe "GET /failed_jobs/:id" do + it "returns 200 and shows the job class" do + execution = create_failed(class_name: "BrokenJob") + get "#{engine_root}/failed_jobs/#{execution.id}" + expect(response).to have_http_status(:ok) + expect(response.body).to include("BrokenJob") + end + + it "shows the error class and message" do + execution = create_failed + get "#{engine_root}/failed_jobs/#{execution.id}" + expect(response.body).to include("RuntimeError") + expect(response.body).to include("something went wrong") + end + + it "shows a breadcrumb back to failed jobs" do + execution = create_failed + get "#{engine_root}/failed_jobs/#{execution.id}" + expect(response.body).to include("sqw-breadcrumb") + expect(response.body).to include("Failed Jobs") + end + + it "renders the argument editor form" do + execution = create_failed + get "#{engine_root}/failed_jobs/#{execution.id}" + expect(response.body).to include("sqw-code-input") + expect(response.body).to include("Update") + end + + it "shows Retry and Discard buttons" do + execution = create_failed + get "#{engine_root}/failed_jobs/#{execution.id}" + expect(response.body).to include("Retry") + expect(response.body).to include("Discard") + end + end + + describe "PATCH /failed_jobs/:id/arguments" do + it "updates arguments and retries the job" do + execution = create_failed + new_args = { "executions" => 0, "exception_executions" => {}, "user_id" => 99 }.to_json + + patch "#{engine_root}/failed_jobs/#{execution.id}/arguments", + params: { arguments: new_args } + + expect(SolidQueue::FailedExecution.exists?(execution.id)).to be false + expect(response).to redirect_to("#{engine_root}/failed_jobs") + end + + it "redirects back with alert on invalid JSON" do + execution = create_failed + + patch "#{engine_root}/failed_jobs/#{execution.id}/arguments", + params: { arguments: "not valid json {{" } + + expect(SolidQueue::FailedExecution.exists?(execution.id)).to be true + expect(response).to redirect_to("#{engine_root}/failed_jobs/#{execution.id}") + end + end + describe "POST /failed_jobs/:id/retry" do it "re-enqueues the job and redirects" do execution = create_failed From 6eb0670c779324e7db7963f1b84b8784b4452c18 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Mon, 25 May 2026 17:30:38 -0400 Subject: [PATCH 2/2] test: add rescue block coverage for selections and arguments controllers Co-Authored-By: Claude Sonnet 4.6 --- .../failed_job_selections_spec.rb | 22 +++++++++++++++++++ .../solid_stack_web/failed_jobs_spec.rb | 20 +++++++++++++++++ spec/requests/solid_stack_web/jobs_spec.rb | 6 +++++ 3 files changed, 48 insertions(+) diff --git a/spec/requests/solid_stack_web/failed_job_selections_spec.rb b/spec/requests/solid_stack_web/failed_job_selections_spec.rb index d82da7e..bd66c05 100644 --- a/spec/requests/solid_stack_web/failed_job_selections_spec.rb +++ b/spec/requests/solid_stack_web/failed_job_selections_spec.rb @@ -19,6 +19,17 @@ def create_failed(class_name: "FailingJob", queue_name: "default") end describe "POST /failed_jobs/selection (bulk retry)" do + it "redirects with alert when retry raises" do + execution = create_failed + allow_any_instance_of(SolidQueue::FailedExecution).to receive(:retry).and_raise(RuntimeError, "db error") + + post "#{engine_root}/failed_jobs/selection", + params: { job_ids: [execution.id] } + + expect(response).to redirect_to("#{engine_root}/failed_jobs") + expect(flash[:alert]).to eq("Could not retry jobs: db error") + end + it "retries only the selected jobs" do exec_a = create_failed(class_name: "JobA") exec_b = create_failed(class_name: "JobB") @@ -59,6 +70,17 @@ def create_failed(class_name: "FailingJob", queue_name: "default") end describe "DELETE /failed_jobs/selection (bulk discard)" do + it "redirects with alert when discard raises" do + execution = create_failed + allow(SolidQueue::Job).to receive(:where).and_raise(RuntimeError, "db error") + + delete "#{engine_root}/failed_jobs/selection", + params: { job_ids: [execution.id] } + + expect(response).to redirect_to("#{engine_root}/failed_jobs") + expect(flash[:alert]).to eq("Could not discard jobs: db error") + end + it "discards only the selected jobs" do exec_a = create_failed(class_name: "JobA") exec_b = create_failed(class_name: "JobB") diff --git a/spec/requests/solid_stack_web/failed_jobs_spec.rb b/spec/requests/solid_stack_web/failed_jobs_spec.rb index 7dd7690..8b6fa1a 100644 --- a/spec/requests/solid_stack_web/failed_jobs_spec.rb +++ b/spec/requests/solid_stack_web/failed_jobs_spec.rb @@ -141,6 +141,15 @@ def create_failed(class_name: "FailingJob", queue_name: "default") expect(response.body).to include("Retry") expect(response.body).to include("Discard") end + + it "falls back to raw arguments string when JSON generation fails" do + execution = create_failed + allow(JSON).to receive(:pretty_generate).and_raise(JSON::GeneratorError, "NaN") + + get "#{engine_root}/failed_jobs/#{execution.id}" + + expect(response).to have_http_status(:ok) + end end describe "PATCH /failed_jobs/:id/arguments" do @@ -164,6 +173,17 @@ def create_failed(class_name: "FailingJob", queue_name: "default") expect(SolidQueue::FailedExecution.exists?(execution.id)).to be true expect(response).to redirect_to("#{engine_root}/failed_jobs/#{execution.id}") end + + it "redirects with alert when update raises" do + execution = create_failed + allow_any_instance_of(SolidQueue::Job).to receive(:update!).and_raise(RuntimeError, "db error") + + patch "#{engine_root}/failed_jobs/#{execution.id}/arguments", + params: { arguments: { "executions" => 0, "exception_executions" => {} }.to_json } + + expect(response).to redirect_to("#{engine_root}/failed_jobs") + expect(flash[:alert]).to eq("Could not update job: db error") + end end describe "POST /failed_jobs/:id/retry" do diff --git a/spec/requests/solid_stack_web/jobs_spec.rb b/spec/requests/solid_stack_web/jobs_spec.rb index 294b44e..e5f52a0 100644 --- a/spec/requests/solid_stack_web/jobs_spec.rb +++ b/spec/requests/solid_stack_web/jobs_spec.rb @@ -298,6 +298,12 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0) expect(response).to redirect_to("#{engine_root}/jobs?status=claimed") end + + it "sets an alert when status is not discardable" do + delete "#{engine_root}/jobs/selection", params: { status: "claimed" } + + expect(flash[:alert]).to eq("Cannot discard claimed jobs.") + end end describe "POST /jobs/discard_all" do