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 +4 -4
- data/CHANGELOG.md +18 -0
- data/lib/ask/ruby/harness/tools/query_database.rb +70 -17
- data/lib/ask/ruby/harness/tools/run_tests.rb +47 -2
- data/lib/ask/ruby/harness/version.rb +1 -1
- data/lib/ask/ruby/harness.rb +35 -7
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b57d0a2408bc2432ed17401048b318263ac9e11709d73cd4eba7b2b47d2e9997
|
|
4
|
+
data.tar.gz: 910fa5fdc3a9a8da190fe05bd5326698043f4615853f3908648c05df55316d51
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
columns =
|
|
47
|
-
rows =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/ask/ruby/harness.rb
CHANGED
|
@@ -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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
212
|
+
yaml[env] || yaml["development"] || {}
|
|
213
|
+
rescue Psych::Exception
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
189
216
|
|
|
190
|
-
|
|
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
|