ask-ruby-harness 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bd5a2b1d1916502e5eb5cc92220a4d06149817a05f2bf8ed00e8327630272881
4
+ data.tar.gz: 847841b9d7fd758f8fb2c3da5d19e8b51414f3e3f2ee94c8e398e3231abe3b6a
5
+ SHA512:
6
+ metadata.gz: 604554da7aa7e84bd2d779cd68123645757d185000d62575694e51201ef1086e5419e48d24c70679546098a4352bf9c686184ff22725fec22d0995aafa6e02f2
7
+ data.tar.gz: 9f4bdf2110e1a2dd490cf59c0d0af545726bec839dc64f59e7cdfe8e6438170750ebe6e2dcc1530cedf51586001b8d7a686897b2c46368eefd60c2a3a25a010f
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ ## [0.1.0] — 2026-08-10
2
+
3
+ ### Added
4
+
5
+ - **Generic harness for any Ruby project** — extracted from
6
+ `ask-rails-harness`: `RunCommand`, `ReadLog`, `QueryDatabase`, `ReadModel`,
7
+ `SchemaGraph`, and `RunTests` with a language-agnostic tool contract.
8
+ - **No Rails dependency** — the gem loads in plain Ruby projects; database
9
+ access connects standalone via `ASK_DATABASE_URL` or `config/database.yml`.
10
+ - **Runner detection for `run_tests`** — `bin/rails test` (Rails apps),
11
+ `bundle exec rspec` (rspec projects), or `bundle exec rake test` (plain
12
+ Ruby projects), all with the same structured JSON results.
13
+ - **Minitest JSON reporter + plugin** — `minitest/ask_ruby_harness_plugin.rb`
14
+ (minitest 5 auto-discovery, minitest 6 RUBYOPT injection), inert unless
15
+ `ASK_TEST_JSON_PATH` is set.
16
+ - **Environment permissions** — per-environment modes and command
17
+ allow/deny lists.
18
+ - **Audit logging** — `ask_audit_logs` table (when available) +
19
+ `audit_log.ask_ruby_harness` notification, with sensitive-param redaction.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kaka Ruto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # Ask Ruby Harness
2
+
3
+ Admin AI copilot for any Ruby project — structured, safe, permission-gated
4
+ access for coding agents. The language edition of the ask-rb harness family
5
+ (`ask-rails-harness` builds on this gem for Rails apps).
6
+
7
+ ## What it gives agents
8
+
9
+ | Tool | What it does |
10
+ |---|---|
11
+ | `QueryDatabase` | Read-only SQL (non-SELECT rejected everywhere; SELECT-only in production) |
12
+ | `ReadModel` | Inspect an ActiveRecord model's columns, associations, validations |
13
+ | `ReadLog` | Read log files with level/search filtering and rotation support |
14
+ | `RunCommand` | Run shell commands in the project root, gated by permission rules |
15
+ | `SchemaGraph` | Full schema introspection: models, tables, columns, associations |
16
+ | `RunTests` | Structured test results with failure reruns (rails test / rspec / rake test) |
17
+
18
+ Generic file and search capabilities (read, grep, edit) are provided by the
19
+ agent's native tools; the harness focuses on what only an app-aware layer
20
+ can give an agent: database access, model introspection, logs, commands, and
21
+ tests — all structured, permission-gated, and audited.
22
+
23
+ ## How it works
24
+
25
+ - **No Rails required.** The harness loads in any Ruby project. Database
26
+ access connects standalone via `ASK_DATABASE_URL` or `config/database.yml`
27
+ when the host hasn't already connected.
28
+ - **Structured returns, never terminal dumps.** Tools return data an agent
29
+ can act on directly — `run_tests` reports counts plus per-test
30
+ file/line/message.
31
+ - **Minitest JSON reporter.** `lib/minitest/ask_ruby_harness_plugin.rb` is
32
+ auto-discovered by minitest 5 and injected via `RUBYOPT` on minitest 6
33
+ (which dropped plugin auto-discovery). Only active when the harness starts
34
+ the run (`ASK_TEST_JSON_PATH` set); ordinary test runs are untouched.
35
+ - **Audit logging.** Every tool call is recorded in `ask_audit_logs` (when
36
+ the table exists) and broadcast as the `audit_log.ask_ruby_harness`
37
+ ActiveSupport notification. Sensitive params are redacted.
38
+ - **Environment permissions.** Per-environment `mode` (full access, read
39
+ only, ask before changes) and command allow/deny lists.
40
+
41
+ ## Usage
42
+
43
+ ```ruby
44
+ Ask::Ruby::Harness.configure do |config|
45
+ config.environment :production do |env|
46
+ env.mode = :read_only
47
+ env.denied_commands = [/rm/, /dropdb/]
48
+ end
49
+ end
50
+
51
+ Ask::Ruby::Harness.discover_tools!
52
+ session = Ask::Ruby::Harness.agent_session(model: "gpt-4o")
53
+ ```
54
+
55
+ The Rails edition (`ask-rails-harness`) mounts an agent at `/ask` and adds
56
+ framework-native tools (routes, engine) on top of this gem.
57
+
58
+ ## Development
59
+
60
+ ```
61
+ bundle install
62
+ bundle exec rake test
63
+ ```
64
+
65
+ ## License
66
+
67
+ MIT — see LICENSE.
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "active_support/core_ext/string/filters"
5
+
6
+ module Ask
7
+ module Ruby
8
+ module Harness
9
+ # Append-only audit log for tool executions.
10
+ #
11
+ # Every tool call made by an agent is recorded in the +ask_audit_logs+
12
+ # table (when one is available) with the intent (sanitized params) and
13
+ # outcome (status, timing), but not the data returned. A
14
+ # +audit_log.ask_ruby_harness+ ActiveSupport notification fires either
15
+ # way, so hosts can subscribe without the table.
16
+ #
17
+ # Sensitive param values (keys matching +password+, +secret+, +token+,
18
+ # +api_key+, +key+) are automatically redacted before logging.
19
+ module AuditLog
20
+ SENSITIVE_KEYS = /\A(password|secret|token|api_key|key|auth_token|access_token)\z/i
21
+
22
+ class << self
23
+ # Log a tool execution event.
24
+ #
25
+ # @param session_id [String] The agent session that triggered this call
26
+ # @param tool_name [String] Name of the tool that ran
27
+ # @param params [Hash] The parameters passed to the tool (sanitized automatically)
28
+ # @param result [Ask::Result, Hash, nil] The result returned by the tool
29
+ # @param error [StandardError, nil] The exception if the tool raised
30
+ # @param duration_ms [Integer] Wall-clock time for the tool execution
31
+ # @param user_context [Hash, nil] Who initiated the session (from config)
32
+ def log(session_id:, tool_name:, params:, result: nil, error: nil, duration_ms:)
33
+ now = Time.now.utc
34
+ entry = {
35
+ session_id: session_id,
36
+ tool_name: tool_name,
37
+ params: sanitize_params(params),
38
+ result_summary: build_summary(tool_name, result, error),
39
+ status: determine_status(result, error),
40
+ error_message: determine_error(result, error),
41
+ duration_ms: duration_ms,
42
+ user_context: resolve_user_context,
43
+ environment: environment_name,
44
+ recorded_at: now,
45
+ created_at: now,
46
+ updated_at: now
47
+ }
48
+
49
+ if table_exists?
50
+ write_entry(entry)
51
+ end
52
+
53
+ # Fire an ActiveSupport notification so host apps can subscribe
54
+ ActiveSupport::Notifications.instrument("audit_log.ask_ruby_harness", entry)
55
+
56
+ entry
57
+ end
58
+
59
+ private
60
+
61
+ def sanitize_params(params)
62
+ return {} unless params.is_a?(Hash)
63
+
64
+ params.each_with_object({}) do |(key, value), sanitized|
65
+ if SENSITIVE_KEYS.match?(key.to_s)
66
+ sanitized[key] = "[REDACTED]"
67
+ else
68
+ sanitized[key] = value
69
+ end
70
+ end
71
+ end
72
+
73
+ def determine_status(result, error)
74
+ return "error" if error
75
+ return "rejected" if result.is_a?(Ask::Result) && (result.error? || result.blocked?)
76
+ "success"
77
+ end
78
+
79
+ def determine_error(result, error)
80
+ return error.message if error
81
+ if result.is_a?(Ask::Result)
82
+ return result.error.to_s if result.error?
83
+ return result.content.to_s if result.blocked?
84
+ end
85
+ nil
86
+ end
87
+
88
+ def extract_data(result)
89
+ return nil unless result
90
+
91
+ if result.is_a?(Ask::Result)
92
+ result.content.is_a?(Hash) ? result.content : nil
93
+ elsif result.is_a?(Hash)
94
+ result
95
+ else
96
+ nil
97
+ end
98
+ end
99
+
100
+ def build_summary(tool_name, result, error)
101
+ if error
102
+ return { error: error.class.name }
103
+ end
104
+
105
+ if result.is_a?(Ask::Result)
106
+ if result.error?
107
+ return { error: "rejected: #{result.error.to_s.truncate(200)}" }
108
+ end
109
+ if result.blocked?
110
+ return { error: "blocked: #{result.content.to_s.truncate(200)}" }
111
+ end
112
+ end
113
+
114
+ data = extract_data(result)
115
+ return {} unless data
116
+
117
+ summary = {}
118
+ summary[:rows] = data[:rows]&.length if data.key?(:rows)
119
+ summary[:columns] = data[:columns]&.length if data.key?(:columns)
120
+ summary[:exit_status] = data[:exit_status] if data.key?(:exit_status)
121
+ summary[:size] = data[:size] if data.key?(:size)
122
+ summary[:matched_lines] = data[:matched_lines] if data.key?(:matched_lines)
123
+ summary[:results] = data[:results]&.length if data.key?(:results)
124
+ summary[:model] = data[:name] if data.key?(:name)
125
+ summary
126
+ end
127
+
128
+ def resolve_user_context
129
+ proc = Ask::Ruby::Harness.configuration.current_user
130
+ return nil unless proc.respond_to?(:call)
131
+
132
+ result = proc.call
133
+ result.is_a?(Hash) ? result : nil
134
+ rescue StandardError
135
+ nil
136
+ end
137
+
138
+ def environment_name
139
+ Ask::Ruby::Harness.env
140
+ end
141
+
142
+ def table_exists?
143
+ return false unless defined?(ActiveRecord::Base)
144
+
145
+ # Only cache the true result — recheck if it was false
146
+ return @table_exists if @table_exists
147
+
148
+ @table_exists = begin
149
+ conn = ActiveRecord::Base.connection
150
+ conn.data_source_exists?("ask_audit_logs")
151
+ rescue StandardError
152
+ false
153
+ end
154
+ end
155
+
156
+ def write_entry(entry)
157
+ # Serialize JSON fields for database storage
158
+ serialized = entry.dup
159
+ %i[params result_summary user_context].each do |key|
160
+ serialized[key] = ::JSON.generate(serialized[key]) if serialized[key].is_a?(Hash)
161
+ end
162
+
163
+ # Use raw SQL to avoid requiring a model class
164
+ columns = serialized.keys
165
+ values = columns.map { |col| ActiveRecord::Base.connection.quote(serialized[col]) }
166
+ ActiveRecord::Base.connection.execute(
167
+ "INSERT INTO ask_audit_logs (#{columns.join(', ')})
168
+ VALUES (#{values.join(', ')})"
169
+ )
170
+ rescue StandardError => e
171
+ # Silently fail — audit log should never crash the caller.
172
+ # ::Rails avoids the bare-`Rails` constant resolving to Ask::Rails;
173
+ # `&.` guards against Rails.logger returning nil (no app booted).
174
+ ::Rails.logger&.warn("[ask-ruby-harness] Audit log write failed: #{e.message}") if defined?(::Rails.logger)
175
+ end
176
+
177
+ # Reset cached table check (useful in tests)
178
+ public
179
+
180
+ def reset_table_check!
181
+ @table_exists = nil
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Ruby
5
+ module Harness
6
+ class Configuration
7
+ # Keys accepted when building a standalone connection from
8
+ # config/database.yml (unknown keys would fail AR's config validation).
9
+ DATABASE_CONFIG_KEYS = %w[adapter database host port username password url encoding pool].freeze
10
+
11
+ attr_accessor :default_model, :max_turns, :system_prompt,
12
+ :tool_concurrency, :persistence_adapter, :tools,
13
+ :current_user, :allowed_commands, :denied_commands,
14
+ :max_session_age, :max_sessions
15
+
16
+ # @return [Hash{Symbol => EnvironmentPermissions}] per-environment permission rules
17
+ attr_reader :environments
18
+
19
+ def initialize
20
+ @default_model = "gpt-4o"
21
+ @max_turns = 25
22
+ @system_prompt = nil
23
+ @tool_concurrency = 5
24
+ @persistence_adapter = nil
25
+ @tools = []
26
+ @current_user = nil
27
+ @allowed_commands = nil
28
+ @denied_commands = nil
29
+ @max_session_age = nil
30
+ @max_sessions = nil
31
+ @environments = {}
32
+ end
33
+
34
+ # Configure permissions for a specific environment.
35
+ #
36
+ # config.environment :production do |env|
37
+ # env.mode = :read_only
38
+ # env.allowed_commands = [/^rails routes/]
39
+ # env.denied_commands = [/rm/, /dropdb/]
40
+ # end
41
+ #
42
+ # @param name [Symbol, String] environment name (:production, :development, :staging, etc.)
43
+ def environment(name)
44
+ env = EnvironmentPermissions.new
45
+ yield env
46
+ @environments[name.to_sym] = env
47
+ end
48
+
49
+ # Resolved allowed commands for the current environment.
50
+ # Falls back to the global +allowed_commands+ if no per-env config.
51
+ #
52
+ # @return [Array<Regexp>, nil]
53
+ def effective_allowed_commands
54
+ env = @environments[Ask::Ruby::Harness.env.to_sym]
55
+ env&.allowed_commands || @allowed_commands
56
+ end
57
+
58
+ # Resolved denied commands for the current environment.
59
+ # Falls back to the global +denied_commands+ if no per-env config.
60
+ #
61
+ # @return [Array<Regexp>, nil]
62
+ def effective_denied_commands
63
+ env = @environments[Ask::Ruby::Harness.env.to_sym]
64
+ env&.denied_commands || @denied_commands
65
+ end
66
+
67
+ # Resolved access mode for the current environment.
68
+ #
69
+ # @return [Symbol, nil] +:full_access+, +:read_only+, +:ask_before_changes+, or nil
70
+ def effective_mode
71
+ env = @environments[Ask::Ruby::Harness.env.to_sym]
72
+ env&.mode || nil
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Ruby
5
+ module Harness
6
+ # Per-environment permission rules for agent tool access.
7
+ #
8
+ # Configure within Ask::Ruby::Harness.configure block:
9
+ #
10
+ # Ask::Ruby::Harness.configure do |config|
11
+ # config.environment :production do |env|
12
+ # env.mode = :read_only
13
+ # env.allowed_commands = [/^rails routes/, /^rails log/]
14
+ # env.denied_commands = [/rm/, /dropdb/]
15
+ # end
16
+ #
17
+ # config.environment :development do |env|
18
+ # env.mode = :full_access
19
+ # end
20
+ # end
21
+ #
22
+ class EnvironmentPermissions
23
+ # @return [Symbol, nil] Access mode for ask-agent's Permissions extension
24
+ # (:full_access, :read_only, :ask_before_changes)
25
+ attr_accessor :mode
26
+
27
+ # @return [Array<Regexp>, nil] Allowed command patterns for RunCommand
28
+ attr_accessor :allowed_commands
29
+
30
+ # @return [Array<Regexp>, nil] Denied command patterns for RunCommand (takes precedence)
31
+ attr_accessor :denied_commands
32
+
33
+ def initialize
34
+ @mode = nil
35
+ @allowed_commands = nil
36
+ @denied_commands = nil
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require "json"
5
+
6
+ module Ask
7
+ module Ruby
8
+ module Harness
9
+ # Writes a machine-readable JSON summary of a minitest run.
10
+ #
11
+ # Registered by the ask_ruby_harness minitest plugin when the
12
+ # ASK_TEST_JSON_PATH env var is set (see the run_tests tool). Produces
13
+ # stable structured results — test name, klass, source file/line, status,
14
+ # and a message head — so the harness never has to parse terminal output.
15
+ #
16
+ # The reporter is inert unless ASK_TEST_JSON_PATH is set, so ordinary
17
+ # `rake test` / `bin/rails test` runs in projects that ship the harness
18
+ # are unaffected.
19
+ class MinitestJsonReporter < Minitest::AbstractReporter
20
+ def initialize(path = ENV["ASK_TEST_JSON_PATH"])
21
+ super()
22
+ @path = path
23
+ @results = []
24
+ end
25
+
26
+ def record(result)
27
+ @results << result
28
+ end
29
+
30
+ def report
31
+ return if @path.nil? || @path.to_s.empty?
32
+ File.write(@path, JSON.pretty_generate(build_report))
33
+ end
34
+
35
+ # Never influences the process exit status — pass/fail is decided by
36
+ # minitest's own summary reporter.
37
+ def passed?
38
+ true
39
+ end
40
+
41
+ private
42
+
43
+ def build_report
44
+ tests = @results.map { |result| test_entry(result) }
45
+ {
46
+ "framework" => "minitest",
47
+ "run" => tests.size,
48
+ "failures" => tests.count { |t| t["status"] == "failed" },
49
+ "errors" => tests.count { |t| t["status"] == "error" },
50
+ "skips" => tests.count { |t| t["status"] == "skipped" },
51
+ "tests" => tests
52
+ }
53
+ end
54
+
55
+ def test_entry(result)
56
+ file, line = result.source_location
57
+ failure = result.failure
58
+ {
59
+ "name" => result.name,
60
+ "klass" => result.klass,
61
+ "file" => file,
62
+ "line" => line,
63
+ "time" => result.time,
64
+ "status" => status_of(result),
65
+ "message" => failure ? message_of(failure) : nil
66
+ }
67
+ end
68
+
69
+ def status_of(result)
70
+ return "skipped" if result.skipped?
71
+ return "error" if result.error?
72
+ return "passed" if result.passed?
73
+ "failed"
74
+ end
75
+
76
+ # First lines of the failure message — enough for an agent to act on
77
+ # without dumping full backtraces into the report.
78
+ def message_of(failure)
79
+ failure.message.to_s.lines.first(3).map(&:strip).reject(&:empty?).join("\n")
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Ruby
5
+ module Harness
6
+ # Base class for harness tools. Provides the app root and audit-logged
7
+ # execution with session correlation. Framework editions subclass this
8
+ # to pin the app root (ask-rails-harness overrides app_root to
9
+ # ::Rails.root via the railtie).
10
+ class Tool < Ask::Tool
11
+ def app_root
12
+ Ask::Ruby::Harness.app_root
13
+ end
14
+
15
+ # Override call to add audit logging around every tool execution.
16
+ # Logs the intent (sanitized params) and outcome (status, timing),
17
+ # but not the returned data.
18
+ def call(args = {}, abort_controller = nil)
19
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ result = super
21
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
22
+
23
+ AuditLog.log(
24
+ session_id: Thread.current[:ask_session_id],
25
+ tool_name: name,
26
+ params: args,
27
+ result: result,
28
+ duration_ms: duration_ms
29
+ )
30
+
31
+ result
32
+ end
33
+
34
+ # Allow the session to set its ID for audit log correlation.
35
+ # Called by the agent loop before executing a tool.
36
+ def self.session_id=(id)
37
+ Thread.current[:ask_session_id] = id
38
+ end
39
+
40
+ def self.session_id
41
+ Thread.current[:ask_session_id]
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Ask
6
+ module Ruby
7
+ module Harness
8
+ module Tools
9
+ class QueryDatabase < Ask::Ruby::Harness::Tool
10
+ description "Run a read-only SQL query against the application database. " \
11
+ "Returns columns and rows. Only SELECT queries are allowed in production."
12
+
13
+ param :sql, type: :string, desc: "SQL query (SELECT only in production)", required: true
14
+ param :limit, type: :integer, desc: "Max rows to return (default 50)", required: false
15
+
16
+ WRITE_STATEMENTS = /\A\s*(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE)\b/i
17
+
18
+ def execute(sql:, limit: 50)
19
+ sql = sql.strip
20
+
21
+ if WRITE_STATEMENTS.match?(sql)
22
+ return Ask::Result.failure(
23
+ "Only SELECT queries are allowed. Write statements (#{sql.match(WRITE_STATEMENTS)[1]}) are rejected in all environments."
24
+ )
25
+ end
26
+
27
+ if Ask::Ruby::Harness.env == "production" && !sql.match?(/\A\s*SELECT\b/i)
28
+ return Ask::Result.failure(
29
+ "Only SELECT queries are allowed in the production environment."
30
+ )
31
+ end
32
+
33
+ unless Ask::Ruby::Harness.database_connected?
34
+ Ask::Ruby::Harness.connect_database!
35
+ end
36
+ unless Ask::Ruby::Harness.database_connected?
37
+ return Ask::Result.failure(
38
+ "Database not connected. Set ASK_DATABASE_URL or provide a config/database.yml."
39
+ )
40
+ end
41
+
42
+ pool = ActiveRecord::Base.connection_pool
43
+ pool.with_connection do |conn|
44
+ 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) }
48
+ {
49
+ columns: columns,
50
+ rows: rows,
51
+ count: rows.size,
52
+ truncated: result.rows.size > limit.to_i
53
+ }
54
+ end
55
+ rescue ActiveRecord::StatementInvalid => e
56
+ Ask::Result.failure("SQL error: #{e.message}")
57
+ rescue ActiveRecord::ConnectionNotEstablished => e
58
+ Ask::Result.failure(
59
+ "Database not connected: #{e.message}. Set ASK_DATABASE_URL or config/database.yml."
60
+ )
61
+ end
62
+
63
+ private
64
+
65
+ def build_row(row, columns)
66
+ columns.each_with_index.each_with_object({}) do |(col, i), hash|
67
+ value = row[i]
68
+ hash[col] = sanitize_value(value)
69
+ end
70
+ end
71
+
72
+ def sanitize_value(value)
73
+ return "[BINARY DATA]" if binary_value?(value)
74
+ return value.iso8601 if value.respond_to?(:iso8601)
75
+ value
76
+ end
77
+
78
+ def binary_value?(value)
79
+ value.is_a?(String) && value.encoding == Encoding::ASCII_8BIT && value.bytesize > 0
80
+ rescue
81
+ false
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end