rails_console_ai 0.33.0 → 0.34.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8af25f720aae1fe703b947bdc860dcace9a13e2e1db61d9d698634fa46838e59
4
- data.tar.gz: efc3b4c00ea66d941e5fb03c61543398fed5ffdde700c44c080ee5e8c8e09ad6
3
+ metadata.gz: 61fcabfed3f03e58e104b7c8b49b86ccf365db5dbae5e1dbf1f8f1b7e63d811c
4
+ data.tar.gz: ec844ca4e1251fb9c1f0c0172095050745ef9690a90defefbcded92eae0962f8
5
5
  SHA512:
6
- metadata.gz: 0b8de788b6391a303815cd160fb57ce4300c33dfd3f66645ce31f9bb256de0922b7ce8d26fd05c23dc8802c80da6201ffb9935c89cfd82b94ebde609b1631733
7
- data.tar.gz: 5c73d678909c5e950cff5191b506a72570c2eb50bd2d6f01602bfe63ec2fade56c94b88d91f6d487803b073abd7b3565c9b92c355f90f7e32be82e465b72b7f9
6
+ metadata.gz: f2294a51714714de69afcc908dccbcb8d5034999973c40b4533600579e8617efc1133ed918ed7925aa082a3afd5598335b5a09527d1a933b38a6ddbf8143d595
7
+ data.tar.gz: bce68674c9467a92eac6b7521e4f1e39f1d06c32b374f38f627513c7ab31bdda57a12a2e937654103a2322d075b594aa56b610cf2ad716f1965f1c2d6123cfad
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.34.0]
6
+
7
+ - Add an `:in_process_requests` built-in safety guard that blocks in-process HTTP dispatch against the app itself — `ActionDispatch::Integration::Session` requests (the console `app` helper) and direct Rack dispatch (`Rails.application.call`) — for all verbs including GET, since these can hang the session thread indefinitely; allowlist entries are request paths
8
+ - Create/update the session record with a `running` status before each turn's tool loop starts, so a turn that hangs or dies mid-loop still leaves a visible session record, and show each session's status in the admin sessions page
9
+ - Make session logging drop attributes the sessions table doesn't have a column for yet, so a gem newer than the table degrades to a partial row instead of losing the insert
10
+ - Fix the sessions page cost display after the pricing refactor
11
+
5
12
  ## [0.33.0]
6
13
 
7
14
  - Support Claude Opus 5 and make it the default model for the Anthropic and Bedrock providers
data/README.md CHANGED
@@ -176,15 +176,17 @@ Safety guards prevent AI-generated code from causing side effects. When a guard
176
176
 
177
177
  ```ruby
178
178
  RailsConsoleAi.configure do |config|
179
- config.use_builtin_safety_guard :database_writes # blocks INSERT/UPDATE/DELETE/DROP/etc.
180
- config.use_builtin_safety_guard :http_mutations # blocks POST/PUT/PATCH/DELETE via Net::HTTP
181
- config.use_builtin_safety_guard :mailers # disables ActionMailer delivery
179
+ config.use_builtin_safety_guard :database_writes # blocks INSERT/UPDATE/DELETE/DROP/etc.
180
+ config.use_builtin_safety_guard :http_mutations # blocks POST/PUT/PATCH/DELETE via Net::HTTP
181
+ config.use_builtin_safety_guard :mailers # disables ActionMailer delivery
182
+ config.use_builtin_safety_guard :in_process_requests # blocks in-process requests against the app itself
182
183
  end
183
184
  ```
184
185
 
185
186
  - **`:database_writes`** — intercepts the ActiveRecord connection adapter to block write SQL. Works on Rails 5+ with any database adapter.
186
187
  - **`:http_mutations`** — intercepts `Net::HTTP#request` to block non-GET/HEAD/OPTIONS requests. Covers libraries built on Net::HTTP (HTTParty, RestClient, Faraday).
187
188
  - **`:mailers`** — sets `ActionMailer::Base.perform_deliveries = false` during execution.
189
+ - **`:in_process_requests`** — blocks `ActionDispatch::Integration::Session` requests (the console `app` helper) and direct Rack dispatch (`Rails.application.call`). These run the app's full middleware stack inside the current process and can deadlock or hang the session thread indefinitely, so **all verbs are blocked, including GET**. Allowlist entries are request paths.
188
190
 
189
191
  ### Custom Guards
190
192
 
@@ -1,7 +1,7 @@
1
1
  module RailsConsoleAi
2
2
  module SessionsHelper
3
3
  def estimated_cost(session)
4
- pricing = Configuration::PRICING[session.model]
4
+ pricing = Configuration.pricing_for(session.model)
5
5
  return nil unless pricing
6
6
 
7
7
  (session.input_tokens * pricing[:input]) + (session.output_tokens * pricing[:output])
@@ -35,6 +35,11 @@
35
35
  .badge-one_shot { background: #d4edda; color: #155724; }
36
36
  .badge-interactive { background: #cce5ff; color: #004085; }
37
37
  .badge-explain { background: #fff3cd; color: #856404; }
38
+ .badge-status-running { background: #fff3cd; color: #856404; }
39
+ .badge-status-queued { background: #e2e3e5; color: #383d41; }
40
+ .badge-status-ready { background: #d4edda; color: #155724; }
41
+ .badge-status-failed { background: #f8d7da; color: #721c24; }
42
+ .badge-status-aborted { background: #f8d7da; color: #721c24; }
38
43
  .meta-card {
39
44
  background: #fff; border-radius: 8px; padding: 20px;
40
45
  box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 24px;
@@ -17,6 +17,7 @@
17
17
  <th>Name</th>
18
18
  <th style="max-width: 400px;">Query</th>
19
19
  <th>Mode</th>
20
+ <th>Status</th>
20
21
  <th>Tokens</th>
21
22
  <th>Cost</th>
22
23
  <th>Duration</th>
@@ -31,6 +32,7 @@
31
32
  <td><%= session.name.present? ? session.name : '-' %></td>
32
33
  <td class="query-cell"><a href="<%= rails_console_ai.session_path(session) %>" title="<%= h session.query.truncate(200) %>"><%= truncate(session.query.gsub(/\s+/, ' ').strip, length: 80) %></a></td>
33
34
  <td><span class="badge badge-<%= session.mode %>"><%= session.mode %></span></td>
35
+ <td><% status = session.try(:status) %><%= status.present? ? content_tag(:span, status, class: "badge badge-status-#{status}") : '-' %></td>
34
36
  <td class="mono"><%= session.input_tokens + session.output_tokens %></td>
35
37
  <td class="mono"><%= format_cost(session) %></td>
36
38
  <td class="mono"><%= session.duration_ms ? "#{session.duration_ms}ms" : '-' %></td>
@@ -72,6 +72,13 @@ RailsConsoleAi.configure do |config|
72
72
  # Built-in guard for mailers — disables ActionMailer delivery:
73
73
  # config.use_builtin_safety_guard :mailers
74
74
  #
75
+ # Built-in guard for in-process requests — blocks ActionDispatch::Integration::Session
76
+ # (the console `app` helper) and direct Rack dispatch against the running app.
77
+ # These run the full middleware stack inside this process and can hang the session
78
+ # thread indefinitely, so ALL verbs are blocked (including GET). Strongly recommended
79
+ # for Slack/API channels:
80
+ # config.use_builtin_safety_guard :in_process_requests
81
+ #
75
82
  # config.safety_guard :jobs do |&execute|
76
83
  # Sidekiq::Testing.fake! { execute.call }
77
84
  # end
@@ -172,12 +172,13 @@ module RailsConsoleAi
172
172
  end
173
173
 
174
174
  # Register a built-in safety guard by name.
175
- # Available: :database_writes, :http_mutations, :mailers
175
+ # Available: :database_writes, :http_mutations, :mailers, :in_process_requests
176
176
  #
177
177
  # Options:
178
178
  # allow: Array of strings or regexps to allowlist for this guard.
179
- # - :http_mutations → hosts (e.g. "s3.amazonaws.com", /googleapis\.com/)
180
- # - :database_writes → table names (e.g. "rails_console_ai_sessions")
179
+ # - :http_mutations → hosts (e.g. "s3.amazonaws.com", /googleapis\.com/)
180
+ # - :database_writes → table names (e.g. "rails_console_ai_sessions")
181
+ # - :in_process_requests → request paths (e.g. "/health")
181
182
  def use_builtin_safety_guard(name, allow: nil)
182
183
  require 'rails_console_ai/safety_guards'
183
184
  guard_name = name.to_sym
@@ -188,8 +189,10 @@ module RailsConsoleAi
188
189
  safety_guards.add(:http_mutations, &BuiltinGuards.http_mutations)
189
190
  when :mailers
190
191
  safety_guards.add(:mailers, &BuiltinGuards.mailers)
192
+ when :in_process_requests
193
+ safety_guards.add(:in_process_requests, &BuiltinGuards.in_process_requests)
191
194
  else
192
- raise ConfigurationError, "Unknown built-in safety guard: #{name}. Available: database_writes, http_mutations, mailers"
195
+ raise ConfigurationError, "Unknown built-in safety guard: #{name}. Available: database_writes, http_mutations, mailers, in_process_requests"
193
196
  end
194
197
 
195
198
  if allow
@@ -242,6 +242,8 @@ module RailsConsoleAi
242
242
  end
243
243
 
244
244
  def execute_direct(raw_code)
245
+ @interactive_query ||= "> #{raw_code}"
246
+ log_interactive_turn(status: 'running')
245
247
  exec_result = @executor.execute_unsafe(raw_code)
246
248
 
247
249
  output_parts = []
@@ -260,14 +262,31 @@ module RailsConsoleAi
260
262
  end
261
263
  @history << { role: :user, content: context_msg, output_id: output_id }
262
264
 
263
- @interactive_query ||= "> #{raw_code}"
264
265
  @last_interactive_code = raw_code
265
266
  @last_interactive_output = @executor.last_output
266
267
  @last_interactive_result = exec_result ? exec_result.inspect : nil
267
268
  @last_interactive_executed = true
269
+ log_interactive_turn(status: 'ready')
268
270
  end
269
271
 
272
+ # Wraps run_turn with session-row status tracking. The row is created/updated
273
+ # with status 'running' BEFORE the tool loop starts, so a turn that hangs or
274
+ # dies mid-loop (hung eval, OOM-killed pod, deploy) still leaves a visible
275
+ # session record instead of vanishing without a trace.
270
276
  def send_and_execute
277
+ log_interactive_turn(status: 'running')
278
+ status = run_turn
279
+ log_interactive_turn(status: status == :error ? 'failed' : 'ready')
280
+ status
281
+ rescue Interrupt
282
+ log_interactive_turn(status: 'ready')
283
+ raise
284
+ rescue StandardError
285
+ log_interactive_turn(status: 'failed')
286
+ raise
287
+ end
288
+
289
+ def run_turn
271
290
  begin
272
291
  result, tool_messages, last_llm_stats = send_query(nil, conversation: @history)
273
292
  rescue Providers::ProviderError => e
@@ -568,7 +587,7 @@ module RailsConsoleAi
568
587
 
569
588
  # --- Session logging ---
570
589
 
571
- def log_interactive_turn
590
+ def log_interactive_turn(status: nil)
572
591
  require 'rails_console_ai/session_logger'
573
592
  session_attrs = {
574
593
  conversation: @history,
@@ -580,6 +599,7 @@ module RailsConsoleAi
580
599
  executed: @last_interactive_executed,
581
600
  console_output: @channel.respond_to?(:console_capture_string) ? @channel.console_capture_string : nil
582
601
  }
602
+ session_attrs[:status] = status if status
583
603
 
584
604
  if @interactive_session_id
585
605
  SessionLogger.update(@interactive_session_id, session_attrs)
@@ -364,6 +364,81 @@ module RailsConsoleAi
364
364
  }
365
365
  end
366
366
 
367
+ # Blocks in-process HTTP dispatch against the running app itself.
368
+ # An ActionDispatch::Integration::Session request (the console `app` helper,
369
+ # or a manually built integration session) runs the app's full middleware
370
+ # stack inside the current process and can deadlock or hang the session
371
+ # thread indefinitely — so ALL verbs are blocked, including GET.
372
+ module InProcessRequestBlocker
373
+ def process(*args, **kwargs, &block)
374
+ RailsConsoleAi::BuiltinGuards.check_in_process_request!(args[0], args[1])
375
+ super
376
+ end
377
+ end
378
+
379
+ # Backstop for the same hazard via direct Rack dispatch
380
+ # (e.g. Rails.application.call(env)), which bypasses Integration::Session.
381
+ module EngineCallBlocker
382
+ def call(env, *args)
383
+ if env.is_a?(Hash)
384
+ RailsConsoleAi::BuiltinGuards.check_in_process_request!(env['REQUEST_METHOD'], env['PATH_INFO'])
385
+ end
386
+ super
387
+ end
388
+ end
389
+
390
+ def self.check_in_process_request!(http_method, path)
391
+ return unless Thread.current[:rails_console_ai_block_in_process_requests]
392
+ return if Thread.current[:rails_console_ai_bypass_guards]
393
+
394
+ key = path.to_s
395
+ guards = RailsConsoleAi.configuration.safety_guards
396
+ return if !key.empty? && guards.allowed?(:in_process_requests, key)
397
+
398
+ label = [http_method.to_s.upcase, key].reject(&:empty?).join(' ')
399
+ raise RailsConsoleAi::SafetyError.new(
400
+ "In-process HTTP request blocked (#{label.empty? ? 'app dispatch' : label}). " \
401
+ "Dispatching a request through the app's own middleware stack from this session " \
402
+ "can hang the process indefinitely, even for GET. Do not retry via another route " \
403
+ "or Rack — call the controller's underlying service or model code directly instead.",
404
+ guard: :in_process_requests,
405
+ blocked_key: key.empty? ? nil : key
406
+ )
407
+ end
408
+
409
+ def self.in_process_requests
410
+ ->(&block) {
411
+ ensure_in_process_blocker_installed!
412
+ prev = Thread.current[:rails_console_ai_block_in_process_requests]
413
+ Thread.current[:rails_console_ai_block_in_process_requests] = true
414
+ begin
415
+ block.call
416
+ ensure
417
+ Thread.current[:rails_console_ai_block_in_process_requests] = prev
418
+ end
419
+ }
420
+ end
421
+
422
+ def self.ensure_in_process_blocker_installed!
423
+ return if @in_process_blocker_installed
424
+
425
+ begin
426
+ require 'action_dispatch'
427
+ require 'action_dispatch/testing/integration'
428
+ rescue LoadError, NameError
429
+ nil # actionpack not (fully) available — the Engine backstop may still apply
430
+ end
431
+
432
+ if defined?(ActionDispatch::Integration::Session) &&
433
+ !ActionDispatch::Integration::Session.ancestors.include?(InProcessRequestBlocker)
434
+ ActionDispatch::Integration::Session.prepend(InProcessRequestBlocker)
435
+ end
436
+ if defined?(Rails::Engine) && !Rails::Engine.ancestors.include?(EngineCallBlocker)
437
+ Rails::Engine.prepend(EngineCallBlocker)
438
+ end
439
+ @in_process_blocker_installed = true
440
+ end
441
+
367
442
  def self.ensure_http_blocker_installed!
368
443
  return if @http_blocker_installed
369
444
 
@@ -32,7 +32,7 @@ module RailsConsoleAi
32
32
  opts = attrs[:options]
33
33
  create_attrs[:options] = opts.is_a?(String) ? opts : opts.to_json
34
34
  end
35
- record = session_class.create!(create_attrs)
35
+ record = session_class.create!(filter_to_columns(create_attrs))
36
36
  record.id
37
37
  rescue => e
38
38
  msg = "RailsConsoleAi: session logging failed: #{e.class}: #{e.message}"
@@ -70,6 +70,7 @@ module RailsConsoleAi
70
70
  updates[:result] = attrs[:result] if attrs.key?(:result)
71
71
  updates[:error_message] = attrs[:error_message] if attrs.key?(:error_message)
72
72
 
73
+ updates = filter_to_columns(updates)
73
74
  session_class.where(id: id).update_all(updates) unless updates.empty?
74
75
  rescue => e
75
76
  msg = "RailsConsoleAi: session update failed: #{e.class}: #{e.message}"
@@ -80,6 +81,17 @@ module RailsConsoleAi
80
81
 
81
82
  private
82
83
 
84
+ # Drop attrs the table doesn't have a column for, so a gem that's newer
85
+ # than the table (e.g. status added before RailsConsoleAi.migrate! ran)
86
+ # degrades to a partial row instead of losing the whole insert.
87
+ def filter_to_columns(attrs)
88
+ return attrs unless session_class.respond_to?(:column_names)
89
+ cols = session_class.column_names.map(&:to_s)
90
+ attrs.select { |k, _| cols.include?(k.to_s) }
91
+ rescue StandardError
92
+ attrs
93
+ end
94
+
83
95
  def table_exists?
84
96
  # Only cache positive results — retry on failure so transient
85
97
  # errors (boot timing, connection not ready) don't stick forever
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.33.0'.freeze
2
+ VERSION = '0.34.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_console_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.33.0
4
+ version: 0.34.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr