maquina-generators 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/generators/maquina/app/app_generator.rb +66 -15
  4. data/lib/generators/maquina/app/templates/.rubocop.yml +6 -0
  5. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/_general_information.html.erb +1 -1
  6. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/jobs/failed/_job.html.erb +2 -2
  7. data/lib/generators/maquina/mission_control_jobs/templates/app/views/mission_control/jobs/shared/_job.html.erb +1 -1
  8. data/lib/generators/maquina/solid_errors/USAGE +24 -0
  9. data/lib/generators/maquina/solid_errors/solid_errors_generator.rb +139 -13
  10. data/lib/generators/maquina/solid_errors/templates/app/agents/error_redactor.rb +34 -0
  11. data/lib/generators/maquina/solid_errors/templates/app/agents/errors_query.rb +84 -0
  12. data/lib/generators/maquina/solid_errors/templates/app/agents/failure_triage.rb +21 -0
  13. data/lib/generators/maquina/solid_errors/templates/app/agents/get_exception_tool.rb +26 -0
  14. data/lib/generators/maquina/solid_errors/templates/app/agents/list_failures_tool.rb +20 -0
  15. data/lib/generators/maquina/solid_errors/templates/app/agents/top_exceptions_tool.rb +18 -0
  16. data/lib/generators/maquina/solid_errors/templates/app/controllers/failures_mcp_controller.rb.tt +65 -0
  17. data/lib/generators/maquina/solid_errors/templates/bin/failures +37 -0
  18. data/lib/generators/maquina/solid_errors/templates/bin/failures-mcp +21 -0
  19. data/lib/maquina_generators/version.rb +1 -1
  20. data/test/generators/maquina/app_generator_test.rb +121 -5
  21. data/test/generators/maquina/failures_mcp_tools_test.rb +37 -0
  22. data/test/generators/maquina/solid_errors_generator_test.rb +140 -0
  23. data/test/tmp/Gemfile +3 -1
  24. data/test/tmp/app/agents/error_redactor.rb +34 -0
  25. data/test/tmp/app/agents/errors_query.rb +84 -0
  26. data/test/tmp/app/agents/failure_triage.rb +21 -0
  27. data/test/tmp/app/agents/get_exception_tool.rb +26 -0
  28. data/test/tmp/app/agents/list_failures_tool.rb +20 -0
  29. data/test/tmp/app/agents/top_exceptions_tool.rb +18 -0
  30. data/test/tmp/app/controllers/backstage_controller.rb +8 -0
  31. data/test/tmp/app/helpers/solid_errors_helper.rb +9 -0
  32. data/test/tmp/app/javascript/controllers/backtrace_filter_controller.js +27 -0
  33. data/test/tmp/app/javascript/controllers/clipboard_controller.js +36 -0
  34. data/test/tmp/app/views/layouts/_admin_navigation.html.erb +28 -0
  35. data/test/tmp/app/views/layouts/solid_errors/application.html.erb +23 -0
  36. data/test/tmp/app/views/solid_errors/errors/_actions.html.erb +22 -0
  37. data/test/tmp/app/views/solid_errors/errors/_delete_button.html.erb +10 -0
  38. data/test/tmp/app/views/solid_errors/errors/_error_card.html.erb +63 -0
  39. data/test/tmp/app/views/solid_errors/errors/_resolve_button.html.erb +11 -0
  40. data/test/tmp/app/views/solid_errors/errors/index.html.erb +37 -0
  41. data/test/tmp/app/views/solid_errors/errors/show/_actions.html.erb +33 -0
  42. data/test/tmp/app/views/solid_errors/errors/show/_error_details.html.erb +95 -0
  43. data/test/tmp/app/views/solid_errors/errors/show/_header.html.erb +18 -0
  44. data/test/tmp/app/views/solid_errors/errors/show/_properties.html.erb +65 -0
  45. data/test/tmp/app/views/solid_errors/errors/show.html.erb +23 -0
  46. data/test/tmp/app/views/solid_errors/occurrences/_backtrace_line.html.erb +27 -0
  47. data/test/tmp/app/views/solid_errors/occurrences/_collection.html.erb +80 -0
  48. data/test/tmp/app/views/solid_errors/occurrences/_occurrence.html.erb +129 -0
  49. data/test/tmp/bin/failures +37 -0
  50. data/test/tmp/bin/failures-mcp +21 -0
  51. data/test/tmp/config/initializers/solid_errors.rb +12 -0
  52. data/test/tmp/config/routes.rb +3 -0
  53. metadata +55 -5
  54. data/test/tmp/Procfile.dev +0 -3
  55. data/test/tmp/config/application.rb +0 -9
  56. data/test/tmp/config/solid_queue.yml +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca10b8ebcd86d74709ad16f64e6366457b53fc9a55da72a997f9084c5819d267
4
- data.tar.gz: 3147bb48486e66744e0b2af74ab06368a7b6e782a77b8c3946069f6c6178d76f
3
+ metadata.gz: 79618453431401c828cd9f89037c0111810fe7ff1b3328986fb99fea3282e010
4
+ data.tar.gz: e33ebfe0accac9a78432b1548648fce1ca8de0176370673f84665a7928eefeef
5
5
  SHA512:
6
- metadata.gz: 6d3ab8cb8656f06fa0c1ced220473bf6bfc76c7931ea70133a91405fdc28b866d2f840f35ced6f9056a382b812a4c03898e2b94ec40f7ab76c39297900789b39
7
- data.tar.gz: 7c5cec9099144f5a9216afe62df79d5c5561d23886accd332017d2e491bf29fe25d787129288f743ec0784d19ff9c85cfe15274f00976c60c554fe018d84007e
6
+ metadata.gz: c5c84a544b6295a6dfd32833b8d65febdb7e9d1de3e79da20c67e55cc7d5102c585a61b76f640ae3eb01cd1e59a57172c7930a90ea86f47bbcf7e49583709cf6
7
+ data.tar.gz: 27361ba6b7f8c51778eee636c43715ce8aa6dcce88c5a6c929c4715d4980b27db240fdbaba8139b3859d920976ad8fc1655881cf965c891099063c63cf88b635
data/README.md CHANGED
@@ -10,13 +10,13 @@ A collection of Rails generators from the Maquina umbrella. Each generator produ
10
10
 
11
11
  #### What it generates
12
12
 
13
- - **Models:** `User`, `Session`, `EmailVerification`, `Current`
13
+ - **Models:** `Account`, `User`, `Session`, `EmailVerification`, `Current`
14
14
  - **Controllers:** Sign-in/sign-up flows with email code verification
15
15
  - **Views:** Minimal, responsive forms styled with Tailwind CSS
16
16
  - **Mailer:** Verification code emails (HTML + text)
17
17
  - **Job:** Cleanup job for expired sessions and verifications
18
18
  - **Locale files:** English and Spanish translations
19
- - **Migrations:** 3 migrations (users, sessions, email_verifications)
19
+ - **Migrations:** 4 migrations (accounts, users, sessions, email_verifications)
20
20
  - **Test helper:** `sign_in_as(user)` and `sign_out` for integration tests
21
21
 
22
22
  #### Installation
@@ -20,7 +20,16 @@ module Maquina
20
20
  content = File.read(gemfile_path)
21
21
 
22
22
  remove_gems = ["rubocop-rails-omakase"]
23
- dev_gems = {"brakeman" => nil, "bundle-audit" => nil, "letter_opener" => nil, "standard" => nil}
23
+ # standard >= 1.54: avoids the inoperative placeholder releases
24
+ # (1.34.0.1 / 1.35.0.1) whose too-loose `rubocop ~> 1.62` requirement
25
+ # makes bundler backtrack onto them and pair them with an incompatible
26
+ # rubocop, so the `standard` binary refuses to run.
27
+ #
28
+ # standard.rb runs through the generated .rubocop.yml (see
29
+ # create_config_files) — that file is standard.rb's runner bridge, not
30
+ # custom RuboCop rules, so it ships even though guidance says "no
31
+ # .rubocop.yml".
32
+ dev_gems = {"brakeman" => nil, "bundle-audit" => nil, "letter_opener" => nil, "standard" => ">= 1.54"}
24
33
  runtime_gems = {"rails-i18n" => nil, "maquina-components" => nil}
25
34
  production_gems = {"aws-sdk-s3" => nil}
26
35
 
@@ -28,22 +37,19 @@ module Maquina
28
37
  gsub_file "Gemfile", /^\s*gem\s+["']#{gem_name}["'].*\n/, ""
29
38
  end
30
39
 
31
- dev_gems.each do |gem_name, _|
32
- unless content.include?("gem \"#{gem_name}\"")
33
- append_to_file "Gemfile", "\ngem \"#{gem_name}\", group: :development\n"
34
- end
40
+ dev_gems.each do |gem_name, version|
41
+ next if content.include?("gem \"#{gem_name}\"")
42
+ append_to_file "Gemfile", "\n#{gem_line(gem_name, version, group: :development)}\n"
35
43
  end
36
44
 
37
- runtime_gems.each do |gem_name, _|
38
- unless content.include?("gem \"#{gem_name}\"")
39
- append_to_file "Gemfile", "\ngem \"#{gem_name}\"\n"
40
- end
45
+ runtime_gems.each do |gem_name, version|
46
+ next if content.include?("gem \"#{gem_name}\"")
47
+ append_to_file "Gemfile", "\n#{gem_line(gem_name, version)}\n"
41
48
  end
42
49
 
43
- production_gems.each do |gem_name, _|
44
- unless content.include?("gem \"#{gem_name}\"")
45
- append_to_file "Gemfile", "\ngem \"#{gem_name}\", group: :production\n"
46
- end
50
+ production_gems.each do |gem_name, version|
51
+ next if content.include?("gem \"#{gem_name}\"")
52
+ append_to_file "Gemfile", "\n#{gem_line(gem_name, version, group: :production)}\n"
47
53
  end
48
54
 
49
55
  return unless rails_app?
@@ -255,7 +261,45 @@ module Maquina
255
261
  end
256
262
  end
257
263
 
258
- # 18. Run all migrations
264
+ # 18. Restore database.yml from its example in bin/setup. config/database.yml
265
+ # is gitignored (it differs per environment), so a fresh clone has only
266
+ # the committed example — bin/setup must copy it before db:prepare.
267
+ def configure_bin_setup
268
+ setup_file = File.join(destination_root, "bin/setup")
269
+ return unless File.exist?(setup_file)
270
+ return if File.read(setup_file).include?("config/database.yml.example")
271
+
272
+ inject_into_file "bin/setup",
273
+ after: %r{system\("bundle check"\) \|\| system!\("bundle install"\)\n} do
274
+ <<~RUBY.indent(2)
275
+
276
+ # config/database.yml is gitignored; restore it from the committed example
277
+ unless File.exist?("config/database.yml")
278
+ puts "\\n== Copying config/database.yml =="
279
+ FileUtils.cp "config/database.yml.example", "config/database.yml"
280
+ end
281
+ RUBY
282
+ end
283
+ end
284
+
285
+ # 19. Restore database.yml from its example in CI. Without it, the test job
286
+ # boots with no database.yml and every Rails task fails. Only touched
287
+ # when the host app already has a GitHub Actions workflow.
288
+ def configure_ci
289
+ ci_file = File.join(destination_root, ".github/workflows/ci.yml")
290
+ return unless File.exist?(ci_file)
291
+ return if File.read(ci_file).include?("config/database.yml.example")
292
+
293
+ inject_into_file ".github/workflows/ci.yml",
294
+ before: /^ - name: Run tests$/ do
295
+ <<~YAML.indent(6)
296
+ - name: Prepare database config
297
+ run: cp config/database.yml.example config/database.yml
298
+ YAML
299
+ end
300
+ end
301
+
302
+ # 20. Run all migrations
259
303
  def run_migrations
260
304
  return unless rails_app?
261
305
 
@@ -264,7 +308,7 @@ module Maquina
264
308
  end
265
309
  end
266
310
 
267
- # 19. Post-install message
311
+ # 21. Post-install message
268
312
  def show_post_install
269
313
  say ""
270
314
  say "=" * 60, :green
@@ -298,6 +342,13 @@ module Maquina
298
342
 
299
343
  private
300
344
 
345
+ def gem_line(name, version, group: nil)
346
+ line = "gem \"#{name}\""
347
+ line << ", \"#{version}\"" if version
348
+ line << ", group: :#{group}" if group
349
+ line
350
+ end
351
+
301
352
  def rails_app?
302
353
  File.exist?(File.join(destination_root, "bin/rails"))
303
354
  end
@@ -1,3 +1,9 @@
1
+ # standard.rb's runner bridge, not custom RuboCop rules.
2
+ #
3
+ # This is the entry point RuboCop (and editors/LSP) need to run the Standard
4
+ # ruleset — all style comes from the `standard` gem via config/base.yml below.
5
+ # Keep this file even though general guidance says "no .rubocop.yml": for
6
+ # standard.rb this IS the runner config, not a custom rule set.
1
7
  require:
2
8
  - standard
3
9
 
@@ -7,7 +7,7 @@
7
7
  <div class="space-y-4">
8
8
  <div>
9
9
  <span class="text-sm text-muted-foreground">Arguments</span>
10
- <div class="mt-1 text-sm font-mono text-foreground break-words"><%= job_arguments(job) %></div>
10
+ <div class="mt-1 text-sm font-mono text-foreground break-words overflow-wrap-anywhere"><%= job_arguments(job) %></div>
11
11
  </div>
12
12
 
13
13
  <div>
@@ -1,7 +1,7 @@
1
1
  <%= render "components/table/cell" do %>
2
- <div>
2
+ <div class="min-w-0">
3
3
  <%= link_to failed_job_error(job), application_job_path(@application, job.job_id, anchor: "error"),
4
- class: "text-sm text-foreground hover:text-primary transition-colors" %>
4
+ class: "block text-sm text-foreground hover:text-primary transition-colors break-words overflow-wrap-anywhere line-clamp-2" %>
5
5
  <div class="text-xs text-muted-foreground mt-1"><%= time_distance_in_words_with_title(job.failed_at) %> ago</div>
6
6
  </div>
7
7
  <% end %>
@@ -12,7 +12,7 @@
12
12
  <% end %>
13
13
  <%= render "components/table/cell" do %>
14
14
  <% if job.serialized_arguments.present? %>
15
- <span class="text-xs font-mono text-muted-foreground"><%= job_arguments(job) %></span>
15
+ <span class="block min-w-0 text-xs font-mono text-muted-foreground break-words overflow-wrap-anywhere line-clamp-2"><%= job_arguments(job) %></span>
16
16
  <% end %>
17
17
  <% end %>
18
18
  <%= render "components/table/cell" do %>
@@ -7,9 +7,33 @@ Description:
7
7
  Authentication tries Rails credentials (backstage.username/password)
8
8
  first, then falls back to environment variables.
9
9
 
10
+ Regardless of options, ApplicationJob is patched to tag reported errors
11
+ with the job class, arguments, and id, so background-job failures appear
12
+ in the Solid Errors dashboard with replayable context.
13
+
14
+ With --agent, it also generates read-only AI-agent triage tooling: query
15
+ and triage objects over the errors store (with a redaction pass), a
16
+ bin/failures runner, and a stdio MCP server (bin/failures-mcp, gem "mcp").
17
+ The agent tooling never writes — resolution stays in the dashboards.
18
+
19
+ With --mcp-http (implies --agent), it additionally generates an
20
+ MCP-over-HTTP endpoint at <prefix>/failures/mcp, behind the same backstage
21
+ Basic Auth as the dashboards, so a developer can query a DEPLOYED app from
22
+ their machine over HTTPS. It self-disables until backstage credentials are
23
+ set. It serves redacted-but-sensitive data over the internet, so enable it
24
+ only where intended and put it behind an IP allowlist/VPN.
25
+
26
+ To query a deployed app without exposing HTTP, run the stdio server where the
27
+ errors DB lives and tunnel its stdio (the post-install prints ssh/docker/kamal
28
+ examples), or copy storage/<env>_errors.sqlite3 down for offline analysis.
29
+
10
30
  Examples:
11
31
  rails g maquina:solid_errors --prefix /admin
12
32
 
13
33
  rails g maquina:solid_errors --prefix /backstage \
14
34
  --user-env-var ADMIN_USER \
15
35
  --password-env-var ADMIN_PASSWORD
36
+
37
+ rails g maquina:solid_errors --prefix /admin --agent
38
+
39
+ rails g maquina:solid_errors --prefix /admin --mcp-http
@@ -13,6 +13,10 @@ module Maquina
13
13
  desc: "Environment variable for HTTP auth password"
14
14
  class_option :copy_views, type: :boolean, default: true,
15
15
  desc: "Copy custom Solid Errors views to the host app"
16
+ class_option :agent, type: :boolean, default: false,
17
+ desc: "Generate read-only AI-agent triage tooling (bin/failures + stdio MCP server)"
18
+ class_option :mcp_http, type: :boolean, default: false,
19
+ desc: "Also expose the MCP server over HTTP behind backstage auth (implies --agent; internet-exposed)"
16
20
  class_option :quiet, type: :boolean, default: false,
17
21
  desc: "Suppress post-install instructions"
18
22
 
@@ -36,25 +40,62 @@ module Maquina
36
40
  "config/initializers/solid_errors.rb"
37
41
  end
38
42
 
39
- # 4. Add gem to Gemfile
43
+ # 4. Enrich job failures with job context (always — benefits the dashboard
44
+ # too, not just the agent). On Solid Queue >= 1.0.2 a failed job's
45
+ # exception already flows to the Rails error reporter that Solid Errors
46
+ # subscribes to; this only attaches the job class/arguments/id to that
47
+ # report so a job failure is distinguishable and replayable.
48
+ def configure_application_job
49
+ job_path = "app/jobs/application_job.rb"
50
+ full_path = File.join(destination_root, job_path)
51
+ return unless File.exist?(full_path)
52
+ return if File.read(full_path).include?("Rails.error.set_context")
53
+
54
+ inject_into_class job_path, "ApplicationJob", <<~RUBY
55
+ # Attach job context to errors reported to Rails.error so background-job
56
+ # failures are distinguishable from request failures in Solid Errors,
57
+ # and carry replayable arguments. Sets context only and lets Solid
58
+ # Queue's built-in reporting forward the exception (reported once).
59
+ #
60
+ # If your Solid Queue version does not propagate this context to its
61
+ # report, fall back to rescuing in the block, reporting explicitly with
62
+ # `Rails.error.report(e, handled: false)`, and RE-RAISING (re-raising is
63
+ # required: it preserves the failed_execution record and retries).
64
+ around_perform do |job, block|
65
+ Rails.error.set_context(
66
+ active_job: job.class.name,
67
+ arguments: job.arguments,
68
+ job_id: job.job_id,
69
+ queue_name: job.queue_name
70
+ )
71
+ block.call
72
+ end
73
+ RUBY
74
+ end
75
+
76
+ # 5. Add gem to Gemfile
40
77
  def add_gem
41
78
  gemfile_path = File.join(destination_root, "Gemfile")
42
- if File.exist?(gemfile_path)
43
- content = File.read(gemfile_path)
44
- unless content.include?('gem "solid_errors"')
45
- append_to_file "Gemfile", "\ngem \"solid_errors\"\n"
46
- end
79
+ return unless File.exist?(gemfile_path)
80
+
81
+ content = File.read(gemfile_path)
82
+ unless content.include?('gem "solid_errors"')
83
+ append_to_file "Gemfile", "\ngem \"solid_errors\"\n"
84
+ end
85
+
86
+ if agent? && !content.include?('gem "mcp"')
87
+ append_to_file "Gemfile", "\ngem \"mcp\"\n"
47
88
  end
48
89
  end
49
90
 
50
- # 5. Routes
91
+ # 6. Routes
51
92
  def add_route
52
93
  mount_path = "#{options[:prefix]}/solid_errors"
53
94
 
54
95
  route "mount SolidErrors::Engine, at: \"#{mount_path}\""
55
96
  end
56
97
 
57
- # 6. Admin navigation
98
+ # 7. Admin navigation
58
99
  def create_admin_navigation
59
100
  nav_path = "app/views/layouts/_admin_navigation.html.erb"
60
101
  return if File.exist?(File.join(destination_root, nav_path))
@@ -62,13 +103,13 @@ module Maquina
62
103
  template "app/views/layouts/_admin_navigation.html.erb.tt", nav_path
63
104
  end
64
105
 
65
- # 7. Layout
106
+ # 8. Layout
66
107
  def copy_layout
67
108
  copy_file "app/views/layouts/solid_errors/application.html.erb",
68
109
  "app/views/layouts/solid_errors/application.html.erb"
69
110
  end
70
111
 
71
- # 8. Stimulus controllers
112
+ # 9. Stimulus controllers
72
113
  def copy_stimulus_controllers
73
114
  copy_file "app/javascript/controllers/clipboard_controller.js",
74
115
  "app/javascript/controllers/clipboard_controller.js"
@@ -76,7 +117,7 @@ module Maquina
76
117
  "app/javascript/controllers/backtrace_filter_controller.js"
77
118
  end
78
119
 
79
- # 9. Custom views
120
+ # 10. Custom views
80
121
  def copy_views
81
122
  return unless options[:copy_views]
82
123
 
@@ -85,7 +126,36 @@ module Maquina
85
126
  end
86
127
  end
87
128
 
88
- # 10. Bundle install
129
+ # 11. AI-agent triage tooling (opt-in). Read-only query/triage layer over
130
+ # the errors store, a bin/ runner, and a stdio MCP server. Resolution
131
+ # stays a human action in the Backstage dashboards.
132
+ def create_agent_files
133
+ return unless agent?
134
+
135
+ agent_files.each do |file|
136
+ copy_file file, file
137
+ end
138
+
139
+ copy_file "bin/failures", "bin/failures"
140
+ copy_file "bin/failures-mcp", "bin/failures-mcp"
141
+ chmod "bin/failures", 0o755
142
+ chmod "bin/failures-mcp", 0o755
143
+ end
144
+
145
+ # 12. MCP-over-HTTP endpoint (opt-in, internet-exposed). Mounts the same
146
+ # read-only tool surface behind the existing backstage Basic Auth so a
147
+ # developer can query a deployed app's failures from their machine.
148
+ def create_mcp_http_controller
149
+ return unless options[:mcp_http]
150
+
151
+ template "app/controllers/failures_mcp_controller.rb.tt",
152
+ "app/controllers/failures_mcp_controller.rb"
153
+
154
+ route %(match "#{options[:prefix]}/failures/mcp", ) +
155
+ %(to: "failures_mcp#handle", via: %i[get post delete])
156
+ end
157
+
158
+ # 13. Bundle install
89
159
  def run_bundle_install
90
160
  return unless rails_app?
91
161
 
@@ -94,7 +164,7 @@ module Maquina
94
164
  end
95
165
  end
96
166
 
97
- # 11. Post-install message
167
+ # 14. Post-install message
98
168
  def show_post_install
99
169
  return if options[:quiet]
100
170
 
@@ -113,10 +183,59 @@ module Maquina
113
183
  say " password: your_password"
114
184
  say " - Or set ENV vars: #{options[:user_env_var]}, #{options[:password_env_var]}"
115
185
  say ""
186
+ say "Job failures: app/jobs/application_job.rb now tags reported errors with", :yellow
187
+ say " job class, arguments, and id so background-job failures show up in the"
188
+ say " Solid Errors dashboard with replayable context (Solid Queue >= 1.0.2)."
189
+ say ""
190
+
191
+ show_agent_post_install if agent?
192
+ show_mcp_http_post_install if options[:mcp_http]
116
193
  end
117
194
 
118
195
  private
119
196
 
197
+ def agent?
198
+ options[:agent] || options[:mcp_http]
199
+ end
200
+
201
+ def show_agent_post_install
202
+ say "AI-agent triage tooling (read-only):", :green
203
+ say ""
204
+ say " bin/failures overview # unresolved errors as JSON"
205
+ say " bin/failures top [limit] # most frequent unresolved"
206
+ say " bin/failures exception <fingerprint> # full detail + backtrace"
207
+ say ""
208
+ say " Register the local stdio MCP server with Claude Code (app root):", :yellow
209
+ say " claude mcp add failures -- bin/failures-mcp"
210
+ say ""
211
+ say " Query a DEPLOYED app from your machine (server runs where the", :yellow
212
+ say " errors DB lives — pick what matches your deploy):"
213
+ say " ssh: claude mcp add failures -- ssh user@host 'cd /app && bin/failures-mcp'"
214
+ say " docker: claude mcp add failures -- ssh user@host 'docker exec -i <container> bin/failures-mcp'"
215
+ say " kamal: claude mcp add failures -- kamal app exec -i --reuse \"bin/failures-mcp\""
216
+ say " offline: copy storage/production_errors.sqlite3 down, run bin/failures locally"
217
+ say ""
218
+ say " Review app/agents/error_redactor.rb — it masks sensitive keys", :yellow
219
+ say " (passwords, tokens, email, ...) before any data reaches the agent."
220
+ say ""
221
+ end
222
+
223
+ def show_mcp_http_post_install
224
+ say "MCP-over-HTTP endpoint (internet-exposed, read-only):", :green
225
+ say ""
226
+ say " Mounted at #{options[:prefix]}/failures/mcp behind backstage Basic Auth."
227
+ say " It self-disables (503) until backstage credentials are set."
228
+ say ""
229
+ say " Register from your machine:", :yellow
230
+ say " claude mcp add --transport http failures \\"
231
+ say " https://yourapp.com#{options[:prefix]}/failures/mcp \\"
232
+ say " --header \"Authorization: Basic $(echo -n user:pass | base64)\""
233
+ say ""
234
+ say " SECURITY: this serves redacted-but-sensitive failure data over the", :red
235
+ say " internet. Enable only where intended and put it behind an IP allowlist/VPN."
236
+ say ""
237
+ end
238
+
120
239
  def rails_app?
121
240
  File.exist?(File.join(destination_root, "bin/rails"))
122
241
  end
@@ -127,6 +246,13 @@ module Maquina
127
246
  File.join("app/views/solid_errors", file)
128
247
  end
129
248
  end
249
+
250
+ def agent_files
251
+ agents_dir = File.join(self.class.source_root, "app/agents")
252
+ Dir.glob("**/*.rb", base: agents_dir).map do |file|
253
+ File.join("app/agents", file)
254
+ end
255
+ end
130
256
  end
131
257
  end
132
258
  end
@@ -0,0 +1,34 @@
1
+ # Redacts sensitive values out of error context and job arguments before they
2
+ # leave the query layer for an AI agent (or any log). Job arguments and request
3
+ # context routinely carry passwords, tokens, and PII; this is the one place that
4
+ # stops them from reaching the agent.
5
+ #
6
+ # Tune SENSITIVE_KEY_PATTERN for your app. Keys that match are masked; everything
7
+ # else passes through untouched. Nested hashes and arrays are walked recursively.
8
+ class ErrorRedactor
9
+ SENSITIVE_KEY_PATTERN = /pass(word)?|secret|token|api[_-]?key|auth|credential|ssn|card|cvv|email/i
10
+ MASK = "[REDACTED]"
11
+
12
+ def self.redact(value)
13
+ new.redact(value)
14
+ end
15
+
16
+ def redact(value)
17
+ case value
18
+ when Hash
19
+ value.each_with_object({}) do |(key, val), acc|
20
+ acc[key] = sensitive?(key) ? MASK : redact(val)
21
+ end
22
+ when Array
23
+ value.map { |item| redact(item) }
24
+ else
25
+ value
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def sensitive?(key)
32
+ SENSITIVE_KEY_PATTERN.match?(key.to_s)
33
+ end
34
+ end
@@ -0,0 +1,84 @@
1
+ # Read-only queries over the Solid Errors store, shaped for agent consumption.
2
+ #
3
+ # This layer NEVER writes. Resolution, retry, and discard stay with the human in
4
+ # the Backstage dashboards. Every context hash and job argument list passes
5
+ # through ErrorRedactor before leaving this class.
6
+ #
7
+ # Failed background jobs reach Solid Errors through the Rails error reporter and
8
+ # carry the job class/arguments attached by ApplicationJob's around_perform hook,
9
+ # so a single read here covers both request and job failures.
10
+ #
11
+ # Column and association names (fingerprint, occurrences_count, recent_occurrence,
12
+ # parsed_backtrace) follow the solid_errors gem schema; confirm against your
13
+ # generated db/errors_schema.rb if you pin an unusual version.
14
+ class ErrorsQuery
15
+ DEFAULT_LIMIT = 25
16
+ TOP_LIMIT = 10
17
+ BACKTRACE_LIMIT = 50
18
+
19
+ class << self
20
+ def unresolved(limit: DEFAULT_LIMIT)
21
+ SolidErrors::Error.where(resolved_at: nil)
22
+ .order(created_at: :desc)
23
+ .limit(limit)
24
+ .map { |error| summarize(error) }
25
+ end
26
+
27
+ def top(limit: TOP_LIMIT)
28
+ SolidErrors::Error.where(resolved_at: nil)
29
+ .order(occurrences_count: :desc)
30
+ .limit(limit)
31
+ .map { |error| summarize(error) }
32
+ end
33
+
34
+ def find(fingerprint)
35
+ detail(SolidErrors::Error.find_by!(fingerprint: fingerprint))
36
+ end
37
+
38
+ private
39
+
40
+ def summarize(error)
41
+ {
42
+ fingerprint: error.fingerprint,
43
+ exception: error.exception_class,
44
+ message: error.message,
45
+ occurrences: error.occurrences_count,
46
+ last_seen: error.recent_occurrence&.created_at,
47
+ job: job_context(error)
48
+ }.compact
49
+ end
50
+
51
+ def detail(error)
52
+ occurrence = error.recent_occurrence
53
+
54
+ summarize(error).merge(
55
+ severity: error.severity,
56
+ source: error.source,
57
+ context: ErrorRedactor.redact(occurrence&.context || {}),
58
+ backtrace: backtrace_lines(occurrence)
59
+ ).compact
60
+ end
61
+
62
+ # Surfaces the job class, redacted arguments, and job_id attached by the
63
+ # ApplicationJob hook. Returns nil for request-sourced errors so the caller
64
+ # can tell a job failure from a controller failure.
65
+ def job_context(error)
66
+ context = (error.recent_occurrence&.context || {}).with_indifferent_access
67
+ return unless (job_class = context[:active_job])
68
+
69
+ {
70
+ class: job_class,
71
+ arguments: ErrorRedactor.redact(context[:arguments]),
72
+ job_id: context[:job_id]
73
+ }.compact
74
+ end
75
+
76
+ def backtrace_lines(occurrence)
77
+ backtrace = occurrence&.parsed_backtrace
78
+ return [] unless backtrace
79
+
80
+ lines = backtrace.is_a?(Array) ? backtrace : backtrace.to_s.lines
81
+ lines.map { |line| line.to_s.strip }.first(BACKTRACE_LIMIT)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,21 @@
1
+ # Single read-only entry point the agent tooling (bin/failures and the MCP
2
+ # server) calls into. Keeping the surface here means bin/ and MCP stay thin and
3
+ # always return the same shapes.
4
+ #
5
+ # Read-only by design: nothing here mutates the errors or queue databases.
6
+ class FailureTriage
7
+ def self.overview(limit: ErrorsQuery::DEFAULT_LIMIT)
8
+ {
9
+ exceptions: ErrorsQuery.unresolved(limit: limit),
10
+ generated_at: Time.current
11
+ }
12
+ end
13
+
14
+ def self.exception(fingerprint)
15
+ ErrorsQuery.find(fingerprint)
16
+ end
17
+
18
+ def self.top(limit: ErrorsQuery::TOP_LIMIT)
19
+ {top_exceptions: ErrorsQuery.top(limit: limit)}
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # MCP tool: full detail for one error by fingerprint — backtrace, redacted
2
+ # context, and (for job failures) the replayable arguments the agent can turn
3
+ # into a regression test.
4
+ class GetExceptionTool < MCP::Tool
5
+ tool_name "get_exception"
6
+ description "Fetch full detail for one error by fingerprint: backtrace, redacted " \
7
+ "context, and — for background-job failures — the redacted arguments that " \
8
+ "triggered it. Get the fingerprint from list_failures or top_exceptions."
9
+
10
+ input_schema(
11
+ properties: {
12
+ fingerprint: {type: "string", description: "Error fingerprint from list_failures."}
13
+ },
14
+ required: ["fingerprint"]
15
+ )
16
+
17
+ def self.call(fingerprint:, server_context: nil)
18
+ payload = FailureTriage.exception(fingerprint)
19
+ MCP::Tool::Response.new([{type: "text", text: JSON.pretty_generate(payload)}])
20
+ rescue ActiveRecord::RecordNotFound
21
+ MCP::Tool::Response.new(
22
+ [{type: "text", text: JSON.pretty_generate(error: "No unresolved error found for fingerprint #{fingerprint}")}],
23
+ error: true
24
+ )
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ # MCP tool: the agent's entry point. Lists unresolved errors (request failures
2
+ # and failed jobs alike), most recent first, with redacted job context where
3
+ # present.
4
+ class ListFailuresTool < MCP::Tool
5
+ tool_name "list_failures"
6
+ description "List unresolved application errors, most recent first. Covers both " \
7
+ "request exceptions and failed background jobs; job failures include their " \
8
+ "class and redacted arguments. Use this first, then get_exception for detail."
9
+
10
+ input_schema(
11
+ properties: {
12
+ limit: {type: "integer", description: "Max errors to return (default 25)."}
13
+ }
14
+ )
15
+
16
+ def self.call(limit: ErrorsQuery::DEFAULT_LIMIT, server_context: nil)
17
+ payload = FailureTriage.overview(limit: limit)
18
+ MCP::Tool::Response.new([{type: "text", text: JSON.pretty_generate(payload)}])
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ # MCP tool: the most frequent unresolved errors by occurrence count — lets the
2
+ # agent prioritize what is failing most, not just what failed most recently.
3
+ class TopExceptionsTool < MCP::Tool
4
+ tool_name "top_exceptions"
5
+ description "List the most frequent unresolved errors by occurrence count, to " \
6
+ "prioritize what is failing most often rather than most recently."
7
+
8
+ input_schema(
9
+ properties: {
10
+ limit: {type: "integer", description: "Max errors to return (default 10)."}
11
+ }
12
+ )
13
+
14
+ def self.call(limit: ErrorsQuery::TOP_LIMIT, server_context: nil)
15
+ payload = FailureTriage.top(limit: limit)
16
+ MCP::Tool::Response.new([{type: "text", text: JSON.pretty_generate(payload)}])
17
+ end
18
+ end