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 @@
+
+
+
+
+
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_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 9aa3f5e..8b6fa1a 100644
--- a/spec/requests/solid_stack_web/failed_jobs_spec.rb
+++ b/spec/requests/solid_stack_web/failed_jobs_spec.rb
@@ -106,6 +106,86 @@ 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
+
+ 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
+ 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
+
+ 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
it "re-enqueues the job and redirects" do
execution = create_failed
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