ask-rails-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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +21 -0
  4. data/README.md +122 -0
  5. data/app/controllers/ask/rails/harness/chat_controller.rb +310 -0
  6. data/app/models/ask/rails/harness/session.rb +25 -0
  7. data/app/views/ask/rails/harness/chat/index.html.erb +6 -0
  8. data/app/views/layouts/ask/rails/harness/application.html.erb +521 -0
  9. data/config/routes.rb +17 -0
  10. data/lib/ask/rails/harness/audit_log.rb +183 -0
  11. data/lib/ask/rails/harness/auth.rb +24 -0
  12. data/lib/ask/rails/harness/configuration.rb +73 -0
  13. data/lib/ask/rails/harness/engine.rb +13 -0
  14. data/lib/ask/rails/harness/environment_permissions.rb +41 -0
  15. data/lib/ask/rails/harness/persistence.rb +54 -0
  16. data/lib/ask/rails/harness/railtie.rb +34 -0
  17. data/lib/ask/rails/harness/service_discovery.rb +50 -0
  18. data/lib/ask/rails/harness/tool.rb +42 -0
  19. data/lib/ask/rails/harness/tools/query_database.rb +74 -0
  20. data/lib/ask/rails/harness/tools/read_file.rb +23 -0
  21. data/lib/ask/rails/harness/tools/read_log.rb +120 -0
  22. data/lib/ask/rails/harness/tools/read_model.rb +75 -0
  23. data/lib/ask/rails/harness/tools/read_routes.rb +20 -0
  24. data/lib/ask/rails/harness/tools/route_inspector.rb +71 -0
  25. data/lib/ask/rails/harness/tools/run_command.rb +60 -0
  26. data/lib/ask/rails/harness/tools/schema_graph.rb +176 -0
  27. data/lib/ask/rails/harness/tools/search_codebase.rb +23 -0
  28. data/lib/ask/rails/harness/version.rb +9 -0
  29. data/lib/ask/rails/harness.rb +189 -0
  30. data/lib/ask/skills/rails.db_debug/SKILL.md +118 -0
  31. data/lib/ask/skills/rails.deploy_pipeline/SKILL.md +125 -0
  32. data/lib/ask/skills/rails.route_trouble/SKILL.md +127 -0
  33. data/lib/ask-rails-harness.rb +3 -0
  34. data/lib/generators/ask/rails/harness/install/install_generator.rb +36 -0
  35. data/lib/generators/ask/rails/harness/install/templates/audit_log_migration.rb +22 -0
  36. data/lib/generators/ask/rails/harness/install/templates/initializer.rb +5 -0
  37. data/lib/generators/ask/rails/harness/install/templates/migration.rb +12 -0
  38. metadata +208 -0
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Rails
5
+ module Harness
6
+ module Tools
7
+ class ReadLog < Ask::Rails::Harness::Tool
8
+ description "Read application log files with filtering. Supports Rails default " \
9
+ "logger and log rotation. Reads from the end of the file (most recent first)."
10
+
11
+ param :lines, type: :integer, desc: "Number of recent lines (default 50, max 500)", required: false
12
+ param :level, type: :string, desc: "Filter by level: ERROR, WARN, INFO, DEBUG", required: false
13
+ param :search, type: :string, desc: "Search term (plain text, case-insensitive)", required: false
14
+ param :file, type: :string, desc: "Log file name (default: log/<env>.log)", required: false
15
+
16
+ MAX_LINES = 500
17
+ LEVEL_PATTERNS = {
18
+ "ERROR" => /\bERROR\b/i,
19
+ "WARN" => /\bWARN\b/i,
20
+ "INFO" => /\bINFO\b/i,
21
+ "DEBUG" => /\bDEBUG\b/i
22
+ }.freeze
23
+
24
+ def execute(lines: 50, level: nil, search: nil, file: nil)
25
+ max_lines = [lines.to_i, MAX_LINES].min
26
+ log_path = resolve_log_path(file)
27
+
28
+ unless log_path.exist?
29
+ return Ask::Result.failure(
30
+ "Log file not found: #{log_path}. The application may not have written any logs yet."
31
+ )
32
+ end
33
+
34
+ raw_lines = read_all_log_files(log_path)
35
+ return { lines: [], total_lines: 0, path: log_path.to_s } if raw_lines.empty?
36
+
37
+ filtered = apply_filters(raw_lines, level: level, search: search)
38
+ recent = filtered.last(max_lines).map(&:chomp)
39
+
40
+ {
41
+ lines: recent,
42
+ total_lines: raw_lines.size,
43
+ matched_lines: filtered.size,
44
+ path: log_path.to_s,
45
+ filters_applied: { level: level, search: search }.compact
46
+ }
47
+ end
48
+
49
+ private
50
+
51
+ def resolve_log_path(custom_path)
52
+ return rails_root.join(custom_path) if custom_path
53
+ rails_root.join("log", "#{Rails.env}.log")
54
+ end
55
+
56
+ # Read from rotated archives too: log/production.log, .1, .2.gz, etc.
57
+ def read_all_log_files(log_path)
58
+ all_content = +""
59
+ rotated_files(log_path).each do |path|
60
+ content = read_file_content(path)
61
+ all_content.prepend(content) if content
62
+ end
63
+ all_content.lines
64
+ end
65
+
66
+ def rotated_files(log_path)
67
+ dir = log_path.dirname
68
+ base = log_path.basename.to_s
69
+ # Primary file, then rotated files in reverse order (oldest first, then primary last)
70
+ pattern = File.join(dir, "#{base}.*")
71
+ rotations = Dir[pattern].sort_by { |f| extract_rotation_number(f) }
72
+ # Primary file is read last (most recent)
73
+ rotations + [log_path.to_s]
74
+ end
75
+
76
+ def extract_rotation_number(path)
77
+ File.basename(path).sub(/.*\.(\d+)(\.gz)?$/, '\1').to_i
78
+ rescue
79
+ 0
80
+ end
81
+
82
+ def read_file_content(path)
83
+ if path.to_s.end_with?(".gz")
84
+ Zlib::GzipReader.open(path.to_s) { |gz| gz.read }
85
+ else
86
+ File.read(path.to_s)
87
+ end
88
+ rescue => e
89
+ warn "[ReadLog] Could not read #{path}: #{e.message}"
90
+ nil
91
+ end
92
+
93
+ def read_tail(path, max_bytes)
94
+ return "" unless path.exist?
95
+
96
+ size = path.size
97
+ return path.read if size <= max_bytes
98
+
99
+ File.open(path, "rb") do |f|
100
+ f.seek(-max_bytes, IO::SEEK_END)
101
+ partial = f.read(max_bytes)
102
+ if (idx = partial.index("\n"))
103
+ partial[idx + 1..]
104
+ else
105
+ partial
106
+ end
107
+ end
108
+ end
109
+
110
+ def apply_filters(lines, level: nil, search: nil)
111
+ filtered = lines
112
+ filtered = filtered.select { |l| LEVEL_PATTERNS.fetch(level) { // }.match?(l) } if level
113
+ filtered = filtered.select { |l| l.downcase.include?(search.downcase) } if search
114
+ filtered
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Rails
5
+ module Harness
6
+ module Tools
7
+ class ReadModel < Ask::Rails::Harness::Tool
8
+ description "Inspect an ActiveRecord model — columns, associations, validations, " \
9
+ "scopes, and indexes. Returns structured data the agent can act on."
10
+
11
+ param :name, type: :string, desc: "Model class name (e.g. 'User', 'Blog::Post')", required: true
12
+ param :detail, type: :string, desc: "Which details: 'all' (default), 'columns', 'associations', 'validations', 'scopes'", required: false
13
+
14
+ def execute(name:, detail: "all")
15
+ klass = safe_constantize(name)
16
+ return Ask::Result.failure("Model '#{name}' not found or is not an ActiveRecord model.") unless klass
17
+
18
+ result = { name: klass.name, table_name: klass.table_name }
19
+
20
+ result[:primary_key] = klass.primary_key if klass.respond_to?(:primary_key)
21
+
22
+ if %w[all columns].include?(detail)
23
+ result[:columns] = klass.columns.map { |c|
24
+ entry = { name: c.name, type: c.type, null: c.null, default: c.default }
25
+ entry[:primary_key] = true if c.name == klass.primary_key
26
+ entry
27
+ }
28
+ end
29
+
30
+ if %w[all associations].include?(detail)
31
+ result[:associations] = klass.reflect_on_all_associations.group_by(&:macro).transform_values { |refs|
32
+ refs.map { |a|
33
+ entry = { name: a.name, class_name: a.class_name }
34
+ entry[:through] = a.options[:through] if a.options[:through]
35
+ entry[:source] = a.options[:source] if a.options[:source]
36
+ entry[:foreign_key] = a.foreign_key if a.respond_to?(:foreign_key)
37
+ entry
38
+ }
39
+ }
40
+ end
41
+
42
+ if %w[all scopes].include?(detail) && klass.respond_to?(:all)
43
+ result[:scopes] = klass.methods(false)
44
+ .reject { |m| m.to_s.end_with?("=", "!", "?") || %i[new allocate inspect to_s].include?(m) }
45
+ .map(&:to_s).sort
46
+ end
47
+
48
+ if %w[all validations].include?(detail)
49
+ result[:validators] = klass.validators.map { |v|
50
+ {
51
+ attribute: v.attributes.first&.to_s,
52
+ kind: v.kind,
53
+ options: v.options.reject { |k, _| k == :if }
54
+ }
55
+ }.reject { |v| v[:attribute].nil? }
56
+ end
57
+
58
+ result
59
+ end
60
+
61
+ private
62
+
63
+ def safe_constantize(name)
64
+ klass = name.safe_constantize
65
+ return nil unless klass
66
+ return nil unless klass < ActiveRecord::Base
67
+ klass
68
+ rescue
69
+ nil
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Rails
5
+ module Harness
6
+ module Tools
7
+ class ReadRoutes < Ask::Rails::Harness::Tool
8
+ description "Read the Rails routes from config/routes.rb"
9
+ def execute
10
+ routes_file = rails_root.join("config", "routes.rb")
11
+ return Ask::Result.error(message: "No routes file found") unless routes_file.exist?
12
+
13
+ content = routes_file.read
14
+ { content: content, size: content.length }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Rails
5
+ module Harness
6
+ module Tools
7
+ class RouteInspector < Ask::Rails::Harness::Tool
8
+ description "Return the parsed Rails route table — every route with its HTTP verb, path, " \
9
+ "controller, action, and name. Returns structured data the agent can filter " \
10
+ "and reason about, unlike reading the raw routes.rb file."
11
+
12
+ param :controller, type: :string, desc: "Filter by controller name (e.g. 'users', 'api/v1/posts')", required: false
13
+ param :pattern, type: :string, desc: "Filter by path pattern (e.g. 'users', 'admin')", required: false
14
+ param :verbose, type: :boolean, desc: "Include internal route details (default false)", required: false
15
+
16
+ def execute(controller: nil, pattern: nil, verbose: false)
17
+ return { routes: [], count: 0 } unless defined?(::Rails) && ::Rails.application&.routes
18
+
19
+ all_routes = ::Rails.application.routes.routes.map do |route|
20
+ entry = {
21
+ verb: verb_for(route),
22
+ path: route.path.spec.to_s.delete_suffix("(.:format)"),
23
+ controller: route.defaults[:controller]&.to_s,
24
+ action: route.defaults[:action]&.to_s,
25
+ name: route.name&.to_s
26
+ }
27
+
28
+ if verbose
29
+ entry[:requirements] = route.required_parts.presence
30
+ entry[:defaults] = route.defaults.except(:controller, :action).presence
31
+ entry[:constraints] = route.constraints.except(:request_method).presence if route.constraints.any?
32
+ end
33
+
34
+ entry
35
+ end
36
+
37
+ # Remove internal/engine routes unless verbose
38
+ unless verbose
39
+ all_routes.reject! { |r| r[:controller].nil? || r[:controller].start_with?("rails/") }
40
+ end
41
+
42
+ # Apply filters
43
+ all_routes.select! { |r| r[:controller] == controller } if controller
44
+ all_routes.select! { |r| r[:path].include?(pattern) } if pattern
45
+
46
+ {
47
+ routes: all_routes,
48
+ count: all_routes.size
49
+ }
50
+ end
51
+
52
+ private
53
+
54
+ def verb_for(route)
55
+ if route.verb.is_a?(String)
56
+ route.verb
57
+ elsif route.verb.respond_to?(:call)
58
+ # Rails stores verb as a regexp — extract the source
59
+ source = route.verb.source
60
+ source.gsub(/[$^\\]/, "").split("|")
61
+ else
62
+ "ANY"
63
+ end
64
+ rescue
65
+ "ANY"
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Rails
5
+ module Harness
6
+ module Tools
7
+ class RunCommand < Ask::Rails::Harness::Tool
8
+ description "Run a shell command in the Rails app root directory."
9
+ param :command, type: :string, desc: "Shell command to run", required: true
10
+
11
+ def execute(command:)
12
+ check_result = check_command_allowed(command)
13
+ return check_result if check_result
14
+
15
+ output = `cd #{rails_root} && #{command} 2>&1`
16
+ Ask::Result.ok(
17
+ data: { output: output, exit_status: $?.exitstatus },
18
+ metadata: { exit_status: $?.exitstatus }
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def check_command_allowed(command)
25
+ config = Ask::Rails::Harness.configuration
26
+
27
+ # Use per-environment rules if configured, fall back to global
28
+ denied = config.effective_denied_commands
29
+ allowed = config.effective_allowed_commands
30
+
31
+ # 1. Check denied commands first (takes precedence)
32
+ if denied
33
+ denied.each do |pattern|
34
+ if command.match?(pattern)
35
+ return Ask::Result.error(
36
+ message: "Command blocked by deny rule: #{pattern.inspect}"
37
+ )
38
+ end
39
+ end
40
+ end
41
+
42
+ # 2. Check allowed commands (if configured)
43
+ if allowed
44
+ matches = allowed.any? { |pattern| command.match?(pattern) }
45
+ unless matches
46
+ allowed_desc = allowed.map(&:inspect).join(", ")
47
+ return Ask::Result.error(
48
+ message: "Command blocked: does not match any allowed pattern (#{allowed_desc})"
49
+ )
50
+ end
51
+ end
52
+
53
+ # 3. No restrictions configured — allow
54
+ nil
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Rails
5
+ module Harness
6
+ module Tools
7
+ class SchemaGraph < Ask::Rails::Harness::Tool
8
+ description "Return the full application schema graph — all models, tables, columns with types, " \
9
+ "associations (belongs_to, has_many, has_one, HABTM, through), validations, indexes, " \
10
+ "and polymorphic relationships. One call gives the agent a complete mental model " \
11
+ "of the application's data layer."
12
+
13
+ param :detail, type: :string, desc: "Detail level: 'all' (default), 'models', 'associations', 'tables'", required: false
14
+
15
+ TABLE_EXCLUSIONS = %w[schema_migrations ar_internal_metadata].freeze
16
+
17
+ def execute(detail: "all")
18
+ models = discover_ar_models
19
+
20
+ result = {
21
+ summary: {
22
+ model_count: models.size,
23
+ table_count: models.map { |m| m.table_name }.uniq.size,
24
+ association_count: models.sum { |m| m.reflect_on_all_associations.size }
25
+ },
26
+ models: %w[all models].include?(detail) ? build_model_details(models, detail) : nil,
27
+ associations: %w[all associations].include?(detail) ? build_association_graph(models) : nil,
28
+ tables: %w[all tables].include?(detail) ? build_table_details(detail) : nil
29
+ }
30
+
31
+ result
32
+ end
33
+
34
+ private
35
+
36
+ def discover_ar_models
37
+ if defined?(::Rails::Application) && ::Rails.application
38
+ ::Rails.application.eager_load! rescue nil
39
+ end
40
+ ActiveRecord::Base.descendants.reject do |klass|
41
+ klass.abstract_class? ||
42
+ klass.name.nil? ||
43
+ TABLE_EXCLUSIONS.include?(klass.table_name) ||
44
+ klass.name.start_with?("ActiveRecord::", "ActiveStorage::", "ActionText::")
45
+ end.sort_by(&:name)
46
+ rescue StandardError
47
+ []
48
+ end
49
+
50
+ def build_model_details(models, detail)
51
+ models.filter_map do |klass|
52
+ name = klass.name rescue nil
53
+ next nil unless name
54
+
55
+ entry = {
56
+ name: name,
57
+ table_name: klass.table_name,
58
+ primary_key: safe_primary_key(klass)
59
+ }
60
+
61
+ if klass.respond_to?(:columns)
62
+ entry[:columns] = safe_columns(klass)
63
+ end
64
+
65
+ if klass.respond_to?(:reflect_on_all_associations)
66
+ entry[:associations] = klass.reflect_on_all_associations.group_by(&:macro).transform_values { |refs|
67
+ refs.map { |a|
68
+ assoc = { name: a.name, class_name: a.class_name }
69
+ assoc[:through] = a.options[:through] if a.options[:through]
70
+ assoc[:source] = a.options[:source] if a.options[:source]
71
+ assoc[:foreign_key] = a.foreign_key
72
+ assoc[:polymorphic] = true if a.polymorphic?
73
+ assoc[:as] = a.options[:as] if a.options[:as]
74
+ assoc[:dependent] = a.options[:dependent] if a.options[:dependent]
75
+ assoc
76
+ }
77
+ }
78
+ end
79
+
80
+ if klass.respond_to?(:validators)
81
+ entry[:validators] = klass.validators.map { |v|
82
+ {
83
+ attribute: v.attributes.first&.to_s,
84
+ kind: v.kind,
85
+ options: v.options.reject { |k, _| k == :if }
86
+ }
87
+ }.reject { |v| v[:attribute].nil? }
88
+ end
89
+
90
+ entry
91
+ end
92
+ end
93
+
94
+ def build_association_graph(models)
95
+ edges = []
96
+
97
+ models.each do |klass|
98
+ klass.reflect_on_all_associations.each do |a|
99
+ next if a.macro == :has_many && a.options[:through] # Skip through associations (derived)
100
+
101
+ target_model = find_model_for_association(models, a)
102
+ next unless target_model
103
+
104
+ edges << {
105
+ from: klass.name,
106
+ to: target_model.name,
107
+ type: a.macro,
108
+ via: a.name,
109
+ foreign_key: a.foreign_key,
110
+ polymorphic: a.polymorphic? || false
111
+ }
112
+ end
113
+ end
114
+
115
+ edges
116
+ end
117
+
118
+ def find_model_for_association(models, association)
119
+ class_name = association.class_name
120
+ # Try exact match first, then match by class name suffix (handles namespace issues)
121
+ models.find { |m| m.name == class_name } ||
122
+ models.find { |m| m.name.end_with?("::#{class_name}") } ||
123
+ models.find { |m| class_name.end_with?("::#{m.name}") }
124
+ end
125
+
126
+ def build_table_details(detail)
127
+ models = discover_ar_models
128
+ tables = {}
129
+
130
+ models.each do |klass|
131
+ table = klass.table_name
132
+ next if TABLE_EXCLUSIONS.include?(table)
133
+
134
+ tables[table] = {
135
+ model: klass.name,
136
+ columns: safe_columns(klass),
137
+ indexes: fetch_indexes_for(table)
138
+ }
139
+ end
140
+
141
+ tables
142
+ end
143
+
144
+ def safe_primary_key(klass)
145
+ klass.primary_key
146
+ rescue StandardError
147
+ nil
148
+ end
149
+
150
+ def safe_columns(klass)
151
+ klass.columns.map { |c|
152
+ col = { name: c.name, type: c.type, null: c.null }
153
+ col[:default] = c.default unless c.default.nil?
154
+ col[:primary_key] = true if c.name == klass.primary_key
155
+ col
156
+ }
157
+ rescue StandardError
158
+ []
159
+ end
160
+
161
+ def fetch_indexes_for(table_name)
162
+ ActiveRecord::Base.connection.indexes(table_name).map do |idx|
163
+ {
164
+ name: idx.name,
165
+ columns: idx.columns,
166
+ unique: idx.unique
167
+ }
168
+ end
169
+ rescue StandardError
170
+ []
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Rails
5
+ module Harness
6
+ module Tools
7
+ class SearchCodebase < Ask::Rails::Harness::Tool
8
+ description "Search the Rails codebase with grep."
9
+ param :pattern, type: :string, desc: "Search pattern", required: true
10
+ param :path, type: :string, desc: "Subdirectory to search (optional)", required: false
11
+
12
+ def execute(pattern:, path: nil)
13
+ search_path = (path ? rails_root.join(path) : rails_root).to_s
14
+ escaped_pattern = pattern.gsub("'", "'\\\\''")
15
+ escaped_path = search_path.gsub("'", "'\\\\''")
16
+ results = `cd #{rails_root} && grep -rn '#{escaped_pattern}' #{escaped_path} 2>&1 | head -50`
17
+ { results: results.lines.map(&:chomp), count: results.lines.count }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Rails
5
+ module Harness
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end