ask-ruby-harness 0.2.1 → 0.3.1

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: 411e139b4a5cfe5e017a748753c36a1f811a45f719e2245c7789267d4fe9f476
4
- data.tar.gz: bd78de0520de347c8ffb675c7c704187c98fc7971b3b4a30770c227479adf7f3
3
+ metadata.gz: b57d0a2408bc2432ed17401048b318263ac9e11709d73cd4eba7b2b47d2e9997
4
+ data.tar.gz: 910fa5fdc3a9a8da190fe05bd5326698043f4615853f3908648c05df55316d51
5
5
  SHA512:
6
- metadata.gz: 4010a7f88a99c289dc68a1bbb40e7b8b829ac794b2a9daf609558128dad5b809f1a6900fae0d8d4c46d795ab69aa5802aa4cfde361256601c913d893f2551000
7
- data.tar.gz: 6c0362992da2df7e54e0191fbc494c35c90641d538c9f2f7ea63e58b8a33a5d4a9248f32ee58def32d5db09547c83887dd598f7dbe524fc98b19a29c04a6f4bb
6
+ metadata.gz: 184e86499d3e1eede9de25e6ee241dc01dfaa2f504ae5de616c7e64cf845b83f12ee2e7681e55b7310078e72435db14f7a806e2526dcced4d70c298cecbb7518
7
+ data.tar.gz: 49e9aae381d3d619787f1420f103b1d09732de6370cb43dc367b5d2d82e8cb6f1043394e449812f53abbe13faff70aa3dd1761a020f8d556f41077e477a2942a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## [0.3.0] — 2026-08-10
2
+
3
+ ### Added
4
+
5
+ - **Multi-database support in `QueryDatabase`** — new `database:` param to
6
+ target any named database: a `config/database.yml` key (resolved first
7
+ through the host app's own configurations registry, so Rails multi-DB
8
+ apps work with credential-resolved configs) or a full connection URL.
9
+ The result reports which database was queried. Write guards apply to all
10
+ databases.
11
+
12
+ ### Fixed
13
+
14
+ - **The gem now requires `active_record` itself** — previously the harness
15
+ only loaded AR when the host did, so standalone processes (the MCP
16
+ server) failed `ASK_DATABASE_URL` connections with "uninitialized
17
+ constant ActiveRecord".
18
+
1
19
  ## [0.2.1] — 2026-08-10
2
20
 
3
21
  ### Fixed
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require "uri"
4
5
 
5
6
  module Ask
6
7
  module Ruby
@@ -8,14 +9,18 @@ module Ask
8
9
  module Tools
9
10
  class QueryDatabase < Ask::Ruby::Harness::Tool
10
11
  description "Run a read-only SQL query against the application database. " \
11
- "Returns columns and rows. Only SELECT queries are allowed in production."
12
+ "Returns columns and rows. Only SELECT queries are allowed in production. " \
13
+ "Multi-database apps can target a named database (a config/database.yml " \
14
+ "key or a full connection URL) with the database param."
12
15
 
13
16
  param :sql, type: :string, desc: "SQL query (SELECT only in production)", required: true
14
17
  param :limit, type: :integer, desc: "Max rows to return (default 50)", required: false
18
+ param :database, type: :string, desc: "Named database from config/database.yml or a full connection URL (default: primary)", required: false
15
19
 
16
20
  WRITE_STATEMENTS = /\A\s*(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE)\b/i
21
+ URL_PATTERN = %r{\A[a-z][a-z0-9+]*://}
17
22
 
18
- def execute(sql:, limit: 50)
23
+ def execute(sql:, limit: 50, database: nil)
19
24
  sql = sql.strip
20
25
 
21
26
  if WRITE_STATEMENTS.match?(sql)
@@ -30,38 +35,86 @@ module Ask
30
35
  )
31
36
  end
32
37
 
33
- unless Ask::Ruby::Harness.database_configured?
34
- Ask::Ruby::Harness.connect_database!
35
- end
36
- unless Ask::Ruby::Harness.database_configured?
37
- return Ask::Result.failure(
38
- "Database not connected. Set ASK_DATABASE_URL or provide a config/database.yml."
39
- )
40
- end
38
+ pool = resolve_pool(database)
39
+ return pool if pool.is_a?(Ask::Result)
41
40
 
42
- pool = ActiveRecord::Base.connection_pool
43
- pool.with_connection do |conn|
41
+ result = pool.with_connection do |conn|
44
42
  limited_sql = sql.match?(/\bLIMIT\b/i) ? sql : "#{sql.chomp(';')} LIMIT #{limit.to_i}"
45
- result = conn.exec_query(limited_sql)
46
- columns = result.columns
47
- rows = result.rows.first(limit.to_i).map { |row| build_row(row, columns) }
43
+ query_result = conn.exec_query(limited_sql)
44
+ columns = query_result.columns
45
+ rows = query_result.rows.first(limit.to_i).map { |row| build_row(row, columns) }
48
46
  {
49
47
  columns: columns,
50
48
  rows: rows,
51
49
  count: rows.size,
52
- truncated: result.rows.size > limit.to_i
50
+ truncated: query_result.rows.size > limit.to_i
53
51
  }
54
52
  end
53
+ result.merge(database: resolved_database_name(database))
55
54
  rescue ActiveRecord::StatementInvalid => e
56
55
  Ask::Result.failure("SQL error: #{e.message}")
57
56
  rescue ActiveRecord::ConnectionNotEstablished => e
58
57
  Ask::Result.failure(
59
- "Database not connected: #{e.message}. Set ASK_DATABASE_URL or config/database.yml."
58
+ "Database not connected: #{e.message}. Set ASK_DATABASE_URL or provide a config/database.yml."
60
59
  )
61
60
  end
62
61
 
63
62
  private
64
63
 
64
+ # Resolve the connection pool for the target database:
65
+ # - nil/"primary" → the default pool (connecting standalone when needed)
66
+ # - a URL → a pool established from that URL
67
+ # - a name → an existing pool (Rails multi-DB), else a pool
68
+ # established from config/database.yml
69
+ def resolve_pool(database)
70
+ if database.nil? || database == "primary"
71
+ unless Ask::Ruby::Harness.database_configured?
72
+ Ask::Ruby::Harness.connect_database!
73
+ end
74
+ unless Ask::Ruby::Harness.database_configured?
75
+ return Ask::Result.failure(
76
+ "Database not connected. Set ASK_DATABASE_URL or provide a config/database.yml."
77
+ )
78
+ end
79
+ return ActiveRecord::Base.connection_pool
80
+ end
81
+
82
+ name = database.to_s
83
+ pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool(name)
84
+ return pool if pool
85
+
86
+ config = url_config(database) || Ask::Ruby::Harness.database_config_for(name)
87
+ unless config
88
+ return Ask::Result.failure(
89
+ "Database '#{database}' not found. Add it to config/database.yml or pass a full connection URL."
90
+ )
91
+ end
92
+
93
+ owner = name.match?(URL_PATTERN) ? "ask_url_#{name.hash.abs}" : name.to_sym
94
+ ActiveRecord::Base.connection_handler.establish_connection(config, owner_name: owner)
95
+ ActiveRecord::Base.connection_handler.retrieve_connection_pool(owner.to_s) ||
96
+ Ask::Result.failure("Could not connect to database '#{database}'.")
97
+ end
98
+
99
+ def url_config(database)
100
+ database if database.to_s.match?(URL_PATTERN)
101
+ end
102
+
103
+ # What to report back as `database`: the config key as passed, or
104
+ # the database name parsed from a URL (never the full URL — it may
105
+ # carry credentials).
106
+ def resolved_database_name(database)
107
+ return "primary" if database.nil? || database == "primary"
108
+ return database unless database.to_s.match?(URL_PATTERN)
109
+
110
+ uri = URI.parse(database)
111
+ name = uri.path.to_s.delete_prefix("/")
112
+ name = File.basename(name) if name.include?("/")
113
+ name.empty? ? uri.host.to_s : name
114
+ rescue URI::InvalidURIError
115
+ database
116
+ end
117
+
65
118
  def build_row(row, columns)
66
119
  columns.each_with_index.each_with_object({}) do |(col, i), hash|
67
120
  value = row[i]
@@ -30,6 +30,14 @@ module Ask
30
30
  files = files.map { |f| rel_to_run_root(f, run_root) }
31
31
  runner = detect_runner(run_root)
32
32
 
33
+ # rake test only takes files (TEST env); expand directory args to
34
+ # their *_test.rb files. Rails and rspec expand dirs natively.
35
+ if runner == :minitest
36
+ expanded = expand_minitest_directories(files, run_root)
37
+ return expanded unless expanded.is_a?(Array)
38
+ files = expanded
39
+ end
40
+
33
41
  failed_tests = failed_only ? load_failed_tests(run_root) : nil
34
42
  if failed_only && failed_tests.empty?
35
43
  return Ask::Result.failure("No failed tests from the previous run to rerun.")
@@ -40,6 +48,10 @@ module Ask
40
48
  json_path = artifact_dir.join("last-test.json")
41
49
  status_path = artifact_dir.join("last-failures.json")
42
50
 
51
+ # A child that produces no JSON must not be masked by a previous
52
+ # run's stale file — parse_results only looks at this path.
53
+ json_path.delete if json_path.exist?
54
+
43
55
  command, env = build_command(runner, files, name, failed_tests, json_path)
44
56
  outcome = run(command, env, log_path, timeout, run_root)
45
57
 
@@ -101,6 +113,25 @@ module Ask
101
113
  Pathname.new(File.expand_path(file, app_root)).relative_path_from(run_root).to_s
102
114
  end
103
115
 
116
+ # rake_test_loader requires each arg as a file — a directory arg
117
+ # explodes ("cannot load such file -- .../test"). Replace directory
118
+ # args with the *_test.rb files under them (relative to run_root),
119
+ # mirroring the standard Rake::TestTask pattern.
120
+ def expand_minitest_directories(files, run_root)
121
+ files.flat_map do |f|
122
+ dir = File.join(run_root.to_s, f)
123
+ next f unless File.directory?(dir)
124
+
125
+ matches = Dir.glob(File.join(dir, "**", "*_test.rb"))
126
+ .map { |m| Pathname.new(m).relative_path_from(run_root).to_s }
127
+ .uniq
128
+ if matches.empty?
129
+ return Ask::Result.failure("No *_test.rb files found under '#{f}'.")
130
+ end
131
+ matches
132
+ end.uniq
133
+ end
134
+
104
135
  def build_command(runner, files, name, failed_tests, json_path)
105
136
  case runner
106
137
  when :rspec
@@ -125,11 +156,25 @@ module Ask
125
156
  # arrives via RUBYOPT like everywhere else.
126
157
  args = ["bundle", "exec", "rake", "test"]
127
158
  env = injection_env(json_path)
128
- env["TEST"] = files.first if files.size == 1
159
+ # Rake::TestTask reads TEST/TESTOPTS from the environment, so
160
+ # the child inherits whatever the parent run carried (nested
161
+ # runs!) unless it's overwritten or cleared here — a stray TEST
162
+ # pointing at the outer project's files would abort the inner
163
+ # run before any test loads.
164
+ env["TEST"] = files.any? ? files.join(",") : nil
129
165
  testopts = []
130
166
  testopts << "--name=#{name}" if name
131
167
  testopts << "--name=#{name_pattern(failed_tests)}" if failed_tests
132
- env["TESTOPTS"] = testopts.join(" ") unless testopts.empty?
168
+ if testopts.empty?
169
+ env["TESTOPTS"] = nil
170
+ else
171
+ # rake's test task runs ruby through the shell, so unquoted
172
+ # TESTOPTS with `|` (failed_only alternations) or spaces gets
173
+ # split into bogus commands. Quote each option so it survives
174
+ # as one ARGV entry in rake_test_loader.
175
+ env["TESTOPTS"] = testopts.map { |o| "\"#{o}\"" }.join(" ")
176
+ end
177
+ %w[TESTOPT TEST_OPTS TEST_OPT].each { |k| env[k] = nil }
133
178
  [args, env]
134
179
  end
135
180
  end
@@ -3,7 +3,7 @@
3
3
  module Ask
4
4
  module Ruby
5
5
  module Harness
6
- VERSION = "0.2.1"
6
+ VERSION = "0.3.1"
7
7
  end
8
8
  end
9
9
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_record"
3
4
  require "ask/agent"
4
5
  require "time"
5
6
  require "yaml"
@@ -93,6 +94,22 @@ module Ask
93
94
  false
94
95
  end
95
96
 
97
+ # Config for a NAMED database. Resolution order:
98
+ # 1. the host app's own configurations registry (Rails multi-DB —
99
+ # resolves credentials/ENV for us),
100
+ # 2. config/database.yml, the environment section's key.
101
+ def database_config_for(name)
102
+ name = name.to_s
103
+ if ActiveRecord::Base.respond_to?(:configurations)
104
+ db = ActiveRecord::Base.configurations.configs_for(env_name: env, name: name)
105
+ return sanitize_database_config(db.configuration_hash.transform_keys(&:to_s)) if db
106
+ end
107
+
108
+ section = database_yaml_section
109
+ config = section[name] if section.is_a?(Hash) && section[name].is_a?(Hash)
110
+ sanitize_database_config(config)
111
+ end
112
+
96
113
  private
97
114
 
98
115
  def build_environment_hooks
@@ -177,17 +194,30 @@ module Ask
177
194
  tools
178
195
  end
179
196
 
197
+ # Config for the primary database: the `primary` section (Rails
198
+ # multi-DB style) or the whole environment section of database.yml.
180
199
  def database_config_from_yaml
200
+ section = database_yaml_section
201
+ config = section["primary"].is_a?(Hash) ? section["primary"] : section
202
+ sanitize_database_config(config)
203
+ end
204
+
205
+ private
206
+
207
+ def database_yaml_section
181
208
  path = app_root.join("config", "database.yml")
182
209
  return nil unless path.exist?
183
210
 
184
211
  yaml = YAML.safe_load(path.read, aliases: true) || {}
185
- section = yaml[env] || yaml["development"] || {}
186
- # Rails-style multi-database config nests the primary under a key.
187
- section = section["primary"] if section.is_a?(Hash) && section["primary"].is_a?(Hash)
188
- return nil unless section.is_a?(Hash)
212
+ yaml[env] || yaml["development"] || {}
213
+ rescue Psych::Exception
214
+ nil
215
+ end
189
216
 
190
- config = section.slice(*Configuration::DATABASE_CONFIG_KEYS)
217
+ def sanitize_database_config(config)
218
+ return nil unless config.is_a?(Hash)
219
+
220
+ config = config.slice(*Configuration::DATABASE_CONFIG_KEYS)
191
221
  # Resolve relative sqlite paths against app_root, like Rails does —
192
222
  # the harness may run with a different cwd than the project root.
193
223
  if config["adapter"].to_s.include?("sqlite") &&
@@ -195,8 +225,6 @@ module Ask
195
225
  config["database"] = app_root.join(config["database"]).to_s
196
226
  end
197
227
  config.presence
198
- rescue Psych::Exception
199
- nil
200
228
  end
201
229
 
202
230
  def default_system_prompt
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-ruby-harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto