ask-rails 0.1.0 → 0.2.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 +4 -4
- data/lib/ask/rails/persistence.rb +7 -15
- data/lib/ask/rails/service_discovery.rb +4 -4
- data/lib/ask/rails/tools/query_database.rb +72 -0
- data/lib/ask/rails/tools/read_log.rb +83 -0
- data/lib/ask/rails/tools/read_model.rb +67 -0
- data/lib/ask/rails/version.rb +1 -1
- data/lib/ask/rails.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e69600ee0def0363ad6aa80623bb2e6c160a5991bb8acf680a18d05eda8c9544
|
|
4
|
+
data.tar.gz: 558d9750d94d02b7de5ab05fcfe8edef68c33f28c6f5a7adf4e0e659856cc0d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40bc9bef781a02d9e020772846393ab0dcce0980b236739f532c87c06a7d7fd2fa24a13118d248a378236a2efc841cae130d61bcfc45230a02aac434626f743e
|
|
7
|
+
data.tar.gz: 3916e3a64be0401e75ff9a00a6febc537aa4d930db9696da1491f18aa0e99d89674aea97b778d1a0c2ca3fe48199e35ba49ff63a11e812d501ddb12590cd173c
|
|
@@ -4,39 +4,31 @@ module Ask
|
|
|
4
4
|
module Rails
|
|
5
5
|
class Persistence
|
|
6
6
|
def initialize(model_class: nil)
|
|
7
|
-
@model_class = model_class
|
|
7
|
+
@model_class = model_class
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def save(session_id, data)
|
|
11
|
-
record =
|
|
11
|
+
record = model_class.find_or_initialize_by(session_id: session_id)
|
|
12
12
|
record.update!(data: data)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def load(session_id)
|
|
16
|
-
record =
|
|
16
|
+
record = model_class.find_by(session_id: session_id)
|
|
17
17
|
record&.data
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def delete(session_id)
|
|
21
|
-
|
|
21
|
+
model_class.where(session_id: session_id).delete_all
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def list
|
|
25
|
-
|
|
25
|
+
model_class.pluck(:session_id)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
private
|
|
29
29
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
ask_session_model = Class.new(::ActiveRecord::Base) do
|
|
33
|
-
self.table_name = "ask_sessions"
|
|
34
|
-
end
|
|
35
|
-
# Store it as a constant so it's reusable
|
|
36
|
-
unless Object.const_defined?(:AskSession)
|
|
37
|
-
Object.const_set(:AskSession, ask_session_model)
|
|
38
|
-
end
|
|
39
|
-
AskSession
|
|
30
|
+
def model_class
|
|
31
|
+
@model_class || (raise "No model class configured. Use Persistence.new(model_class: MyModel)")
|
|
40
32
|
end
|
|
41
33
|
end
|
|
42
34
|
end
|
|
@@ -14,10 +14,9 @@ module Ask
|
|
|
14
14
|
service_gems.each do |name|
|
|
15
15
|
begin
|
|
16
16
|
require "#{name.tr("-", "/")}/context"
|
|
17
|
-
mod = name.
|
|
18
|
-
contexts << mod if mod.const_defined?(:DESCRIPTION)
|
|
17
|
+
mod = name.split("-").map(&:capitalize).join.constantize
|
|
18
|
+
contexts << mod if mod.respond_to?(:const_defined?) && mod.const_defined?(:DESCRIPTION)
|
|
19
19
|
rescue LoadError, NameError
|
|
20
|
-
# No context module for this gem
|
|
21
20
|
end
|
|
22
21
|
end
|
|
23
22
|
|
|
@@ -34,7 +33,8 @@ module Ask
|
|
|
34
33
|
sections = ["## Available Services"]
|
|
35
34
|
|
|
36
35
|
contexts.each do |mod|
|
|
37
|
-
|
|
36
|
+
name = mod.respond_to?(:name) ? mod.name.to_s.split("::").last || "Unknown" : "Unknown"
|
|
37
|
+
sections << "### #{name}"
|
|
38
38
|
sections << mod::DESCRIPTION if mod.const_defined?(:DESCRIPTION)
|
|
39
39
|
sections << "Documentation: #{mod::DOCS_URL}" if mod.const_defined?(:DOCS_URL)
|
|
40
40
|
sections << "Authentication: #{mod::AUTH_HOW}" if mod.const_defined?(:AUTH_HOW)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Tools
|
|
6
|
+
class QueryDatabase < Ask::Rails::Tool
|
|
7
|
+
description "Run a read-only SQL query against the application database. " \
|
|
8
|
+
"Returns columns and rows. Only SELECT queries are allowed in production."
|
|
9
|
+
|
|
10
|
+
param :sql, type: :string, desc: "SQL query (SELECT only in production)", required: true
|
|
11
|
+
param :limit, type: :integer, desc: "Max rows to return (default 50)", required: false
|
|
12
|
+
|
|
13
|
+
WRITE_STATEMENTS = /\A\s*(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE)\b/i
|
|
14
|
+
|
|
15
|
+
def execute(sql:, limit: 50)
|
|
16
|
+
sql = sql.strip
|
|
17
|
+
|
|
18
|
+
if WRITE_STATEMENTS.match?(sql)
|
|
19
|
+
return Ask::Result.failure(
|
|
20
|
+
"Only SELECT queries are allowed. Write statements (#{sql.match(WRITE_STATEMENTS)[1]}) are rejected in all environments."
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if Rails.env.production? && !sql.match?(/\A\s*SELECT\b/i)
|
|
25
|
+
return Ask::Result.failure(
|
|
26
|
+
"Only SELECT queries are allowed in the production environment."
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
pool = ActiveRecord::Base.connection_pool
|
|
31
|
+
pool.with_connection do |conn|
|
|
32
|
+
limited_sql = sql.match?(/\bLIMIT\b/i) ? sql : "#{sql.chomp(';')} LIMIT #{limit.to_i}"
|
|
33
|
+
result = conn.exec_query(limited_sql)
|
|
34
|
+
columns = result.columns
|
|
35
|
+
rows = result.rows.first(limit.to_i).map { |row| build_row(row, columns) }
|
|
36
|
+
{
|
|
37
|
+
columns: columns,
|
|
38
|
+
rows: rows,
|
|
39
|
+
count: rows.size,
|
|
40
|
+
truncated: result.rows.size > limit.to_i
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
44
|
+
Ask::Result.failure("SQL error: #{e.message}")
|
|
45
|
+
rescue ActiveRecord::ConnectionNotEstablished => e
|
|
46
|
+
Ask::Result.failure("Database not connected: #{e.message}. Verify the database is running and Rails is connected.")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build_row(row, columns)
|
|
52
|
+
columns.each_with_index.each_with_object({}) do |(col, i), hash|
|
|
53
|
+
value = row[i]
|
|
54
|
+
hash[col] = sanitize_value(value)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def sanitize_value(value)
|
|
59
|
+
return "[BINARY DATA]" if binary_value?(value)
|
|
60
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def binary_value?(value)
|
|
65
|
+
value.is_a?(String) && value.encoding == Encoding::ASCII_8BIT && value.bytesize > 0
|
|
66
|
+
rescue
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Tools
|
|
6
|
+
class ReadLog < Ask::Rails::Tool
|
|
7
|
+
description "Read application log files with filtering. Supports Rails default " \
|
|
8
|
+
"logger and log rotation. Reads from the end of the file (most recent first)."
|
|
9
|
+
|
|
10
|
+
param :lines, type: :integer, desc: "Number of recent lines (default 50, max 500)", required: false
|
|
11
|
+
param :level, type: :string, desc: "Filter by level: ERROR, WARN, INFO, DEBUG", required: false
|
|
12
|
+
param :search, type: :string, desc: "Search term (plain text, case-insensitive)", required: false
|
|
13
|
+
param :file, type: :string, desc: "Log file name (default: log/<env>.log)", required: false
|
|
14
|
+
|
|
15
|
+
MAX_LINES = 500
|
|
16
|
+
LEVEL_PATTERNS = {
|
|
17
|
+
"ERROR" => /\bERROR\b/,
|
|
18
|
+
"WARN" => /\bWARN\b/,
|
|
19
|
+
"INFO" => /\bINFO\b/,
|
|
20
|
+
"DEBUG" => /\bDEBUG\b/
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def execute(lines: 50, level: nil, search: nil, file: nil)
|
|
24
|
+
lines = [lines.to_i, MAX_LINES].min
|
|
25
|
+
log_path = resolve_log_path(file)
|
|
26
|
+
|
|
27
|
+
unless log_path.exist?
|
|
28
|
+
return Ask::Result.failure(
|
|
29
|
+
"Log file not found: #{log_path}. The application may not have written any logs yet."
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
content = read_tail(log_path, MAX_LINES * 2)
|
|
34
|
+
return { lines: [], total_lines: 0, path: log_path.to_s } if content.empty?
|
|
35
|
+
|
|
36
|
+
raw_lines = content.lines
|
|
37
|
+
|
|
38
|
+
filtered = apply_filters(raw_lines, level: level, search: search)
|
|
39
|
+
recent = filtered.last(lines).map(&:chomp)
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
lines: recent,
|
|
43
|
+
total_lines: raw_lines.size,
|
|
44
|
+
matched_lines: filtered.size,
|
|
45
|
+
path: log_path.to_s,
|
|
46
|
+
filters_applied: { level: level, search: search }.compact
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def resolve_log_path(custom_path)
|
|
53
|
+
return rails_root.join(custom_path) if custom_path
|
|
54
|
+
rails_root.join("log", "#{Rails.env}.log")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def read_tail(path, max_bytes)
|
|
58
|
+
return "" unless path.exist?
|
|
59
|
+
|
|
60
|
+
size = path.size
|
|
61
|
+
return path.read if size <= max_bytes
|
|
62
|
+
|
|
63
|
+
File.open(path, "rb") do |f|
|
|
64
|
+
f.seek(-max_bytes, IO::SEEK_END)
|
|
65
|
+
partial = f.read(max_bytes)
|
|
66
|
+
if (idx = partial.index("\n"))
|
|
67
|
+
partial[idx + 1..]
|
|
68
|
+
else
|
|
69
|
+
partial
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def apply_filters(lines, level: nil, search: nil)
|
|
75
|
+
filtered = lines
|
|
76
|
+
filtered = filtered.select { |l| LEVEL_PATTERNS.fetch(level) { |_| // }.match?(l) } if level
|
|
77
|
+
filtered = filtered.select { |l| l.downcase.include?(search.downcase) } if search
|
|
78
|
+
filtered
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Tools
|
|
6
|
+
class ReadModel < Ask::Rails::Tool
|
|
7
|
+
description "Inspect an ActiveRecord model — columns, associations, validations, " \
|
|
8
|
+
"scopes, and indexes. Returns structured data the agent can act on."
|
|
9
|
+
|
|
10
|
+
param :name, type: :string, desc: "Model class name (e.g. 'User', 'Blog::Post')", required: true
|
|
11
|
+
param :detail, type: :string, desc: "Which details: 'all' (default), 'columns', 'associations', 'validations'", required: false
|
|
12
|
+
|
|
13
|
+
def execute(name:, detail: "all")
|
|
14
|
+
klass = safe_constantize(name)
|
|
15
|
+
return Ask::Result.failure("Model '#{name}' not found or is not an ActiveRecord model.") unless klass
|
|
16
|
+
|
|
17
|
+
result = { name: klass.name, table_name: klass.table_name }
|
|
18
|
+
|
|
19
|
+
result[:primary_key] = klass.primary_key if klass.respond_to?(:primary_key)
|
|
20
|
+
|
|
21
|
+
if %w[all columns].include?(detail)
|
|
22
|
+
result[:columns] = klass.columns.map { |c|
|
|
23
|
+
entry = { name: c.name, type: c.type, null: c.null, default: c.default }
|
|
24
|
+
entry[:primary_key] = true if c.name == klass.primary_key
|
|
25
|
+
entry
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if %w[all associations].include?(detail)
|
|
30
|
+
result[:associations] = klass.reflect_on_all_associations.group_by(&:macro).transform_values { |refs|
|
|
31
|
+
refs.map { |a|
|
|
32
|
+
entry = { name: a.name, class_name: a.class_name }
|
|
33
|
+
entry[:through] = a.options[:through] if a.options[:through]
|
|
34
|
+
entry[:source] = a.options[:source] if a.options[:source]
|
|
35
|
+
entry[:foreign_key] = a.foreign_key if a.respond_to?(:foreign_key)
|
|
36
|
+
entry
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if %w[all validations].include?(detail)
|
|
42
|
+
result[:validators] = klass.validators.map { |v|
|
|
43
|
+
{
|
|
44
|
+
attribute: v.attributes.first&.to_s,
|
|
45
|
+
kind: v.kind,
|
|
46
|
+
options: v.options.reject { |k, _| k == :if }
|
|
47
|
+
}
|
|
48
|
+
}.reject { |v| v[:attribute].nil? }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def safe_constantize(name)
|
|
57
|
+
klass = name.safe_constantize
|
|
58
|
+
return nil unless klass
|
|
59
|
+
return nil unless klass < ActiveRecord::Base
|
|
60
|
+
klass
|
|
61
|
+
rescue
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/ask/rails/version.rb
CHANGED
data/lib/ask/rails.rb
CHANGED
|
@@ -74,3 +74,6 @@ require_relative "rails/tools/read_file"
|
|
|
74
74
|
require_relative "rails/tools/run_command"
|
|
75
75
|
require_relative "rails/tools/search_codebase"
|
|
76
76
|
require_relative "rails/tools/read_routes"
|
|
77
|
+
require_relative "rails/tools/query_database"
|
|
78
|
+
require_relative "rails/tools/read_model"
|
|
79
|
+
require_relative "rails/tools/read_log"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -152,7 +152,10 @@ files:
|
|
|
152
152
|
- lib/ask/rails/railtie.rb
|
|
153
153
|
- lib/ask/rails/service_discovery.rb
|
|
154
154
|
- lib/ask/rails/tool.rb
|
|
155
|
+
- lib/ask/rails/tools/query_database.rb
|
|
155
156
|
- lib/ask/rails/tools/read_file.rb
|
|
157
|
+
- lib/ask/rails/tools/read_log.rb
|
|
158
|
+
- lib/ask/rails/tools/read_model.rb
|
|
156
159
|
- lib/ask/rails/tools/read_routes.rb
|
|
157
160
|
- lib/ask/rails/tools/run_command.rb
|
|
158
161
|
- lib/ask/rails/tools/search_codebase.rb
|