codebase_index 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 (171) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/CODE_OF_CONDUCT.md +83 -0
  4. data/CONTRIBUTING.md +65 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +481 -0
  7. data/exe/codebase-console-mcp +22 -0
  8. data/exe/codebase-index-mcp +61 -0
  9. data/exe/codebase-index-mcp-http +64 -0
  10. data/exe/codebase-index-mcp-start +58 -0
  11. data/lib/codebase_index/ast/call_site_extractor.rb +106 -0
  12. data/lib/codebase_index/ast/method_extractor.rb +76 -0
  13. data/lib/codebase_index/ast/node.rb +88 -0
  14. data/lib/codebase_index/ast/parser.rb +653 -0
  15. data/lib/codebase_index/ast.rb +6 -0
  16. data/lib/codebase_index/builder.rb +137 -0
  17. data/lib/codebase_index/chunking/chunk.rb +84 -0
  18. data/lib/codebase_index/chunking/semantic_chunker.rb +290 -0
  19. data/lib/codebase_index/console/adapters/cache_adapter.rb +58 -0
  20. data/lib/codebase_index/console/adapters/good_job_adapter.rb +66 -0
  21. data/lib/codebase_index/console/adapters/sidekiq_adapter.rb +66 -0
  22. data/lib/codebase_index/console/adapters/solid_queue_adapter.rb +66 -0
  23. data/lib/codebase_index/console/audit_logger.rb +75 -0
  24. data/lib/codebase_index/console/bridge.rb +170 -0
  25. data/lib/codebase_index/console/confirmation.rb +90 -0
  26. data/lib/codebase_index/console/connection_manager.rb +173 -0
  27. data/lib/codebase_index/console/console_response_renderer.rb +78 -0
  28. data/lib/codebase_index/console/model_validator.rb +81 -0
  29. data/lib/codebase_index/console/safe_context.rb +82 -0
  30. data/lib/codebase_index/console/server.rb +557 -0
  31. data/lib/codebase_index/console/sql_validator.rb +172 -0
  32. data/lib/codebase_index/console/tools/tier1.rb +118 -0
  33. data/lib/codebase_index/console/tools/tier2.rb +117 -0
  34. data/lib/codebase_index/console/tools/tier3.rb +110 -0
  35. data/lib/codebase_index/console/tools/tier4.rb +79 -0
  36. data/lib/codebase_index/coordination/pipeline_lock.rb +109 -0
  37. data/lib/codebase_index/cost_model/embedding_cost.rb +88 -0
  38. data/lib/codebase_index/cost_model/estimator.rb +128 -0
  39. data/lib/codebase_index/cost_model/provider_pricing.rb +67 -0
  40. data/lib/codebase_index/cost_model/storage_cost.rb +52 -0
  41. data/lib/codebase_index/cost_model.rb +22 -0
  42. data/lib/codebase_index/db/migrations/001_create_units.rb +38 -0
  43. data/lib/codebase_index/db/migrations/002_create_edges.rb +35 -0
  44. data/lib/codebase_index/db/migrations/003_create_embeddings.rb +37 -0
  45. data/lib/codebase_index/db/migrations/004_create_snapshots.rb +45 -0
  46. data/lib/codebase_index/db/migrations/005_create_snapshot_units.rb +40 -0
  47. data/lib/codebase_index/db/migrator.rb +71 -0
  48. data/lib/codebase_index/db/schema_version.rb +73 -0
  49. data/lib/codebase_index/dependency_graph.rb +227 -0
  50. data/lib/codebase_index/embedding/indexer.rb +130 -0
  51. data/lib/codebase_index/embedding/openai.rb +105 -0
  52. data/lib/codebase_index/embedding/provider.rb +135 -0
  53. data/lib/codebase_index/embedding/text_preparer.rb +112 -0
  54. data/lib/codebase_index/evaluation/baseline_runner.rb +115 -0
  55. data/lib/codebase_index/evaluation/evaluator.rb +146 -0
  56. data/lib/codebase_index/evaluation/metrics.rb +79 -0
  57. data/lib/codebase_index/evaluation/query_set.rb +148 -0
  58. data/lib/codebase_index/evaluation/report_generator.rb +90 -0
  59. data/lib/codebase_index/extracted_unit.rb +145 -0
  60. data/lib/codebase_index/extractor.rb +956 -0
  61. data/lib/codebase_index/extractors/action_cable_extractor.rb +228 -0
  62. data/lib/codebase_index/extractors/ast_source_extraction.rb +46 -0
  63. data/lib/codebase_index/extractors/behavioral_profile.rb +309 -0
  64. data/lib/codebase_index/extractors/caching_extractor.rb +261 -0
  65. data/lib/codebase_index/extractors/callback_analyzer.rb +232 -0
  66. data/lib/codebase_index/extractors/concern_extractor.rb +253 -0
  67. data/lib/codebase_index/extractors/configuration_extractor.rb +219 -0
  68. data/lib/codebase_index/extractors/controller_extractor.rb +494 -0
  69. data/lib/codebase_index/extractors/database_view_extractor.rb +278 -0
  70. data/lib/codebase_index/extractors/decorator_extractor.rb +260 -0
  71. data/lib/codebase_index/extractors/engine_extractor.rb +204 -0
  72. data/lib/codebase_index/extractors/event_extractor.rb +211 -0
  73. data/lib/codebase_index/extractors/factory_extractor.rb +289 -0
  74. data/lib/codebase_index/extractors/graphql_extractor.rb +917 -0
  75. data/lib/codebase_index/extractors/i18n_extractor.rb +117 -0
  76. data/lib/codebase_index/extractors/job_extractor.rb +369 -0
  77. data/lib/codebase_index/extractors/lib_extractor.rb +249 -0
  78. data/lib/codebase_index/extractors/mailer_extractor.rb +339 -0
  79. data/lib/codebase_index/extractors/manager_extractor.rb +202 -0
  80. data/lib/codebase_index/extractors/middleware_extractor.rb +133 -0
  81. data/lib/codebase_index/extractors/migration_extractor.rb +469 -0
  82. data/lib/codebase_index/extractors/model_extractor.rb +960 -0
  83. data/lib/codebase_index/extractors/phlex_extractor.rb +252 -0
  84. data/lib/codebase_index/extractors/policy_extractor.rb +214 -0
  85. data/lib/codebase_index/extractors/poro_extractor.rb +246 -0
  86. data/lib/codebase_index/extractors/pundit_extractor.rb +223 -0
  87. data/lib/codebase_index/extractors/rails_source_extractor.rb +473 -0
  88. data/lib/codebase_index/extractors/rake_task_extractor.rb +343 -0
  89. data/lib/codebase_index/extractors/route_extractor.rb +181 -0
  90. data/lib/codebase_index/extractors/scheduled_job_extractor.rb +331 -0
  91. data/lib/codebase_index/extractors/serializer_extractor.rb +334 -0
  92. data/lib/codebase_index/extractors/service_extractor.rb +254 -0
  93. data/lib/codebase_index/extractors/shared_dependency_scanner.rb +91 -0
  94. data/lib/codebase_index/extractors/shared_utility_methods.rb +99 -0
  95. data/lib/codebase_index/extractors/state_machine_extractor.rb +398 -0
  96. data/lib/codebase_index/extractors/test_mapping_extractor.rb +225 -0
  97. data/lib/codebase_index/extractors/validator_extractor.rb +225 -0
  98. data/lib/codebase_index/extractors/view_component_extractor.rb +310 -0
  99. data/lib/codebase_index/extractors/view_template_extractor.rb +261 -0
  100. data/lib/codebase_index/feedback/gap_detector.rb +89 -0
  101. data/lib/codebase_index/feedback/store.rb +119 -0
  102. data/lib/codebase_index/flow_analysis/operation_extractor.rb +209 -0
  103. data/lib/codebase_index/flow_analysis/response_code_mapper.rb +154 -0
  104. data/lib/codebase_index/flow_assembler.rb +290 -0
  105. data/lib/codebase_index/flow_document.rb +191 -0
  106. data/lib/codebase_index/flow_precomputer.rb +102 -0
  107. data/lib/codebase_index/formatting/base.rb +40 -0
  108. data/lib/codebase_index/formatting/claude_adapter.rb +98 -0
  109. data/lib/codebase_index/formatting/generic_adapter.rb +56 -0
  110. data/lib/codebase_index/formatting/gpt_adapter.rb +64 -0
  111. data/lib/codebase_index/formatting/human_adapter.rb +78 -0
  112. data/lib/codebase_index/graph_analyzer.rb +374 -0
  113. data/lib/codebase_index/mcp/index_reader.rb +394 -0
  114. data/lib/codebase_index/mcp/renderers/claude_renderer.rb +81 -0
  115. data/lib/codebase_index/mcp/renderers/json_renderer.rb +17 -0
  116. data/lib/codebase_index/mcp/renderers/markdown_renderer.rb +352 -0
  117. data/lib/codebase_index/mcp/renderers/plain_renderer.rb +240 -0
  118. data/lib/codebase_index/mcp/server.rb +935 -0
  119. data/lib/codebase_index/mcp/tool_response_renderer.rb +62 -0
  120. data/lib/codebase_index/model_name_cache.rb +51 -0
  121. data/lib/codebase_index/notion/client.rb +217 -0
  122. data/lib/codebase_index/notion/exporter.rb +219 -0
  123. data/lib/codebase_index/notion/mapper.rb +39 -0
  124. data/lib/codebase_index/notion/mappers/column_mapper.rb +65 -0
  125. data/lib/codebase_index/notion/mappers/migration_mapper.rb +39 -0
  126. data/lib/codebase_index/notion/mappers/model_mapper.rb +164 -0
  127. data/lib/codebase_index/notion/rate_limiter.rb +68 -0
  128. data/lib/codebase_index/observability/health_check.rb +81 -0
  129. data/lib/codebase_index/observability/instrumentation.rb +34 -0
  130. data/lib/codebase_index/observability/structured_logger.rb +75 -0
  131. data/lib/codebase_index/operator/error_escalator.rb +81 -0
  132. data/lib/codebase_index/operator/pipeline_guard.rb +99 -0
  133. data/lib/codebase_index/operator/status_reporter.rb +80 -0
  134. data/lib/codebase_index/railtie.rb +26 -0
  135. data/lib/codebase_index/resilience/circuit_breaker.rb +99 -0
  136. data/lib/codebase_index/resilience/index_validator.rb +185 -0
  137. data/lib/codebase_index/resilience/retryable_provider.rb +108 -0
  138. data/lib/codebase_index/retrieval/context_assembler.rb +249 -0
  139. data/lib/codebase_index/retrieval/query_classifier.rb +131 -0
  140. data/lib/codebase_index/retrieval/ranker.rb +273 -0
  141. data/lib/codebase_index/retrieval/search_executor.rb +327 -0
  142. data/lib/codebase_index/retriever.rb +160 -0
  143. data/lib/codebase_index/ruby_analyzer/class_analyzer.rb +190 -0
  144. data/lib/codebase_index/ruby_analyzer/dataflow_analyzer.rb +78 -0
  145. data/lib/codebase_index/ruby_analyzer/fqn_builder.rb +18 -0
  146. data/lib/codebase_index/ruby_analyzer/mermaid_renderer.rb +275 -0
  147. data/lib/codebase_index/ruby_analyzer/method_analyzer.rb +143 -0
  148. data/lib/codebase_index/ruby_analyzer/trace_enricher.rb +139 -0
  149. data/lib/codebase_index/ruby_analyzer.rb +87 -0
  150. data/lib/codebase_index/session_tracer/file_store.rb +111 -0
  151. data/lib/codebase_index/session_tracer/middleware.rb +143 -0
  152. data/lib/codebase_index/session_tracer/redis_store.rb +112 -0
  153. data/lib/codebase_index/session_tracer/session_flow_assembler.rb +263 -0
  154. data/lib/codebase_index/session_tracer/session_flow_document.rb +223 -0
  155. data/lib/codebase_index/session_tracer/solid_cache_store.rb +145 -0
  156. data/lib/codebase_index/session_tracer/store.rb +67 -0
  157. data/lib/codebase_index/storage/graph_store.rb +120 -0
  158. data/lib/codebase_index/storage/metadata_store.rb +169 -0
  159. data/lib/codebase_index/storage/pgvector.rb +163 -0
  160. data/lib/codebase_index/storage/qdrant.rb +172 -0
  161. data/lib/codebase_index/storage/vector_store.rb +156 -0
  162. data/lib/codebase_index/temporal/snapshot_store.rb +341 -0
  163. data/lib/codebase_index/version.rb +5 -0
  164. data/lib/codebase_index.rb +223 -0
  165. data/lib/generators/codebase_index/install_generator.rb +32 -0
  166. data/lib/generators/codebase_index/pgvector_generator.rb +37 -0
  167. data/lib/generators/codebase_index/templates/add_pgvector_to_codebase_index.rb.erb +15 -0
  168. data/lib/generators/codebase_index/templates/create_codebase_index_tables.rb.erb +43 -0
  169. data/lib/tasks/codebase_index.rake +583 -0
  170. data/lib/tasks/codebase_index_evaluation.rake +115 -0
  171. metadata +252 -0
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CodebaseIndex
4
+ module Console
5
+ module Adapters
6
+ # Job backend adapter for Solid Queue.
7
+ #
8
+ # Builds bridge requests for Solid Queue job stats, failure listing,
9
+ # job lookup, scheduled jobs, and retry operations.
10
+ #
11
+ # @example
12
+ # adapter = SolidQueueAdapter.new
13
+ # adapter.queue_stats # => { tool: 'solid_queue_queue_stats', params: {} }
14
+ #
15
+ class SolidQueueAdapter
16
+ # Check if Solid Queue is available in the current environment.
17
+ #
18
+ # @return [Boolean]
19
+ def self.available?
20
+ defined?(::SolidQueue) ? true : false
21
+ end
22
+
23
+ # Get queue statistics (sizes, latencies).
24
+ #
25
+ # @return [Hash] Bridge request
26
+ def queue_stats
27
+ { tool: 'solid_queue_queue_stats', params: {} }
28
+ end
29
+
30
+ # List recent job failures.
31
+ #
32
+ # @param limit [Integer] Max failures (default: 10, max: 100)
33
+ # @return [Hash] Bridge request
34
+ def recent_failures(limit: 10)
35
+ limit = [limit, 100].min
36
+ { tool: 'solid_queue_recent_failures', params: { limit: limit } }
37
+ end
38
+
39
+ # Find a job by its ID.
40
+ #
41
+ # @param id [Object] Solid Queue job ID
42
+ # @return [Hash] Bridge request
43
+ def find_job(id:)
44
+ { tool: 'solid_queue_find_job', params: { id: id } }
45
+ end
46
+
47
+ # List scheduled jobs.
48
+ #
49
+ # @param limit [Integer] Max jobs (default: 20, max: 100)
50
+ # @return [Hash] Bridge request
51
+ def scheduled_jobs(limit: 20)
52
+ limit = [limit, 100].min
53
+ { tool: 'solid_queue_scheduled_jobs', params: { limit: limit } }
54
+ end
55
+
56
+ # Retry a failed job.
57
+ #
58
+ # @param id [Object] Solid Queue job ID
59
+ # @return [Hash] Bridge request
60
+ def retry_job(id:)
61
+ { tool: 'solid_queue_retry_job', params: { id: id } }
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module CodebaseIndex
7
+ module Console
8
+ # Logs all Tier 4 tool invocations to a JSONL file.
9
+ #
10
+ # Each line is a JSON object with: tool name, params, timestamp,
11
+ # confirmation status, and result summary.
12
+ #
13
+ # @example
14
+ # logger = AuditLogger.new(path: 'log/console_audit.jsonl')
15
+ # logger.log(tool: 'console_eval', params: { code: '1+1' },
16
+ # confirmed: true, result_summary: '2')
17
+ # logger.entries # => [{ "tool" => "console_eval", ... }]
18
+ #
19
+ class AuditLogger
20
+ # @param path [String] Path to the JSONL audit log file
21
+ def initialize(path:)
22
+ @path = path
23
+ end
24
+
25
+ # Write an audit entry.
26
+ #
27
+ # @param tool [String] Tool name
28
+ # @param params [Hash] Tool parameters
29
+ # @param confirmed [Boolean] Whether confirmation was granted
30
+ # @param result_summary [String] Brief result description
31
+ # @return [void]
32
+ def log(tool:, params:, confirmed:, result_summary:)
33
+ ensure_directory!
34
+
35
+ entry = {
36
+ tool: tool,
37
+ params: params,
38
+ confirmed: confirmed,
39
+ result_summary: result_summary,
40
+ timestamp: Time.now.utc.iso8601
41
+ }
42
+
43
+ File.open(@path, 'a') { |f| f.puts(JSON.generate(entry)) }
44
+ end
45
+
46
+ # Read all audit entries.
47
+ #
48
+ # @return [Array<Hash>] Parsed JSONL entries
49
+ def entries
50
+ return [] unless File.exist?(@path)
51
+
52
+ File.readlines(@path).filter_map do |line|
53
+ JSON.parse(line.strip) unless line.strip.empty?
54
+ end
55
+ end
56
+
57
+ # Number of audit entries.
58
+ #
59
+ # @return [Integer]
60
+ def size
61
+ entries.size
62
+ end
63
+
64
+ private
65
+
66
+ # Ensure the parent directory of the log file exists.
67
+ #
68
+ # @return [void]
69
+ def ensure_directory!
70
+ dir = File.dirname(@path)
71
+ FileUtils.mkdir_p(dir)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'model_validator'
5
+ require_relative 'safe_context'
6
+
7
+ module CodebaseIndex
8
+ module Console
9
+ # JSON-lines protocol bridge between MCP server and Rails environment.
10
+ #
11
+ # Reads JSON-lines requests from an input IO, validates model/column names,
12
+ # dispatches to tool handlers, and writes JSON-lines responses to an output IO.
13
+ #
14
+ # Protocol:
15
+ # Request: {"id":"req_1","tool":"count","params":{"model":"Order","scope":{"status":"pending"}}}
16
+ # Response: {"id":"req_1","ok":true,"result":{"count":1847},"timing_ms":12.3}
17
+ # Error: {"id":"req_1","ok":false,"error":"Model not found","error_type":"validation"}
18
+ #
19
+ # @example
20
+ # bridge = Bridge.new(input: $stdin, output: $stdout,
21
+ # model_validator: validator, safe_context: ctx)
22
+ # bridge.run
23
+ #
24
+ class Bridge
25
+ SUPPORTED_TOOLS = %w[count sample find pluck aggregate association_count schema recent status].freeze
26
+ TOOL_HANDLERS = SUPPORTED_TOOLS.to_h { |t| [t, :"handle_#{t}"] }.freeze
27
+
28
+ # @param input [IO] Input stream (reads JSON-lines)
29
+ # @param output [IO] Output stream (writes JSON-lines)
30
+ # @param model_validator [ModelValidator] Validates model/column names
31
+ # @param safe_context [SafeContext] Wraps execution in safe transaction
32
+ def initialize(input:, output:, model_validator:, safe_context:)
33
+ @input = input
34
+ @output = output
35
+ @model_validator = model_validator
36
+ @safe_context = safe_context
37
+ end
38
+
39
+ # Read loop — processes requests until input is closed.
40
+ #
41
+ # @return [void]
42
+ def run
43
+ @input.each_line do |line|
44
+ line = line.strip
45
+ next if line.empty?
46
+
47
+ request = parse_request(line)
48
+ next unless request
49
+
50
+ response = handle_request(request)
51
+ write_response(response)
52
+ end
53
+ end
54
+
55
+ # Process a single request hash and return a response hash.
56
+ #
57
+ # @param request [Hash] Parsed request with "id", "tool", "params"
58
+ # @return [Hash] Response with "id", "ok", and "result" or "error"
59
+ def handle_request(request)
60
+ id = request['id']
61
+ tool = request['tool']
62
+ params = request['params'] || {}
63
+
64
+ return error_response(id, "Unknown tool: #{tool}", 'unknown_tool') unless SUPPORTED_TOOLS.include?(tool)
65
+
66
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ result = dispatch(tool, params)
68
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
69
+
70
+ { 'id' => id, 'ok' => true, 'result' => result, 'timing_ms' => elapsed }
71
+ rescue ValidationError => e
72
+ error_response(id, e.message, 'validation')
73
+ rescue StandardError => e
74
+ error_response(id, e.message, 'execution')
75
+ end
76
+
77
+ private
78
+
79
+ # Parse a JSON line into a request hash.
80
+ #
81
+ # @param line [String] Raw JSON line
82
+ # @return [Hash, nil] Parsed request or nil on parse error
83
+ def parse_request(line)
84
+ JSON.parse(line)
85
+ rescue JSON::ParserError => e
86
+ write_response(error_response(nil, "Invalid JSON: #{e.message}", 'parse'))
87
+ nil
88
+ end
89
+
90
+ # Dispatch a tool request to the appropriate handler.
91
+ #
92
+ # @param tool [String] Tool name
93
+ # @param params [Hash] Tool parameters
94
+ # @return [Hash] Tool result
95
+ def dispatch(tool, params)
96
+ case tool
97
+ when 'status'
98
+ handle_status
99
+ when 'schema'
100
+ handle_schema(params)
101
+ else
102
+ validate_model_param(params)
103
+ handler = TOOL_HANDLERS.fetch(tool) { raise ValidationError, "Unknown tool: #{tool}" }
104
+ send(handler, params)
105
+ end
106
+ end
107
+
108
+ # Validate that the model parameter is present and known.
109
+ def validate_model_param(params)
110
+ model = params['model']
111
+ raise ValidationError, 'Missing required parameter: model' unless model
112
+
113
+ @model_validator.validate_model!(model)
114
+ end
115
+
116
+ def handle_count(_params)
117
+ { 'count' => 0 }
118
+ end
119
+
120
+ def handle_sample(_params)
121
+ { 'records' => [] }
122
+ end
123
+
124
+ def handle_find(_params)
125
+ { 'record' => nil }
126
+ end
127
+
128
+ def handle_pluck(params)
129
+ @model_validator.validate_columns!(params['model'], params['columns']) if params['columns']
130
+ { 'values' => [] }
131
+ end
132
+
133
+ def handle_aggregate(params)
134
+ @model_validator.validate_column!(params['model'], params['column']) if params['column']
135
+ { 'value' => nil }
136
+ end
137
+
138
+ def handle_association_count(_params)
139
+ { 'count' => 0 }
140
+ end
141
+
142
+ def handle_schema(params)
143
+ model = params['model']
144
+ raise ValidationError, 'Missing required parameter: model' unless model
145
+
146
+ @model_validator.validate_model!(model)
147
+ { 'columns' => @model_validator.columns_for(model), 'indexes' => [] }
148
+ end
149
+
150
+ def handle_recent(_params)
151
+ { 'records' => [] }
152
+ end
153
+
154
+ def handle_status
155
+ { 'status' => 'ok', 'models' => @model_validator.model_names }
156
+ end
157
+
158
+ # Build an error response hash.
159
+ def error_response(id, message, error_type)
160
+ { 'id' => id, 'ok' => false, 'error' => message, 'error_type' => error_type }
161
+ end
162
+
163
+ # Write a JSON-line response to the output stream.
164
+ def write_response(response)
165
+ @output.puts(JSON.generate(response))
166
+ @output.flush
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @see CodebaseIndex
4
+ module CodebaseIndex
5
+ class Error < StandardError; end unless defined?(CodebaseIndex::Error)
6
+
7
+ module Console
8
+ class ConfirmationDeniedError < CodebaseIndex::Error; end
9
+
10
+ # Human-in-the-loop confirmation protocol for Tier 4 tools.
11
+ #
12
+ # Supports three modes:
13
+ # - `:auto_approve` — Always approve (for testing/trusted environments)
14
+ # - `:auto_deny` — Always deny (for locked-down environments)
15
+ # - `:callback` — Delegates to a callable that returns true/false
16
+ #
17
+ # Tracks confirmation history for audit purposes.
18
+ #
19
+ # @example Auto-approve mode
20
+ # confirmation = Confirmation.new(mode: :auto_approve)
21
+ # confirmation.request_confirmation(tool: 'eval', description: '1+1', params: {})
22
+ # # => true
23
+ #
24
+ # @example Callback mode
25
+ # confirmation = Confirmation.new(mode: :callback, callback: ->(req) { req[:tool] != 'eval' })
26
+ # confirmation.request_confirmation(tool: 'sql', description: 'SELECT 1', params: {})
27
+ # # => true
28
+ #
29
+ class Confirmation
30
+ VALID_MODES = %i[auto_approve auto_deny callback].freeze
31
+
32
+ # @return [Array<Hash>] History of confirmation requests and outcomes
33
+ attr_reader :history
34
+
35
+ # @param mode [Symbol] One of :auto_approve, :auto_deny, :callback
36
+ # @param callback [Proc, nil] Required when mode is :callback
37
+ # @raise [ArgumentError] if mode is invalid or callback is missing for callback mode
38
+ def initialize(mode:, callback: nil)
39
+ unless VALID_MODES.include?(mode)
40
+ raise ArgumentError, "Invalid mode: #{mode}. Must be one of: #{VALID_MODES.join(', ')}"
41
+ end
42
+
43
+ raise ArgumentError, 'Callback required for callback mode' if mode == :callback && callback.nil?
44
+
45
+ @mode = mode
46
+ @callback = callback
47
+ @history = []
48
+ end
49
+
50
+ # Request confirmation for a Tier 4 operation.
51
+ #
52
+ # @param tool [String] Tool name
53
+ # @param description [String] Human-readable description of the action
54
+ # @param params [Hash] Tool parameters
55
+ # @return [true] if confirmed
56
+ # @raise [ConfirmationDeniedError] if denied
57
+ def request_confirmation(tool:, description:, params:) # rubocop:disable Naming/PredicateMethod
58
+ approved = evaluate(tool: tool, description: description, params: params)
59
+
60
+ @history << {
61
+ tool: tool,
62
+ description: description,
63
+ params: params,
64
+ approved: approved,
65
+ timestamp: Time.now.utc.iso8601
66
+ }
67
+
68
+ raise ConfirmationDeniedError, "Confirmation denied for #{tool}: #{description}" unless approved
69
+
70
+ true
71
+ end
72
+
73
+ private
74
+
75
+ # Evaluate the confirmation based on the current mode.
76
+ #
77
+ # @return [Boolean]
78
+ def evaluate(tool:, description:, params:)
79
+ case @mode
80
+ when :auto_approve
81
+ true
82
+ when :auto_deny
83
+ false
84
+ when :callback
85
+ @callback.call({ tool: tool, description: description, params: params })
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'shellwords'
6
+
7
+ # @see CodebaseIndex
8
+ module CodebaseIndex
9
+ class Error < StandardError; end unless defined?(CodebaseIndex::Error)
10
+
11
+ module Console
12
+ class ConnectionError < CodebaseIndex::Error; end
13
+
14
+ # Manages the bridge process connection via Docker exec, direct spawn, or SSH.
15
+ #
16
+ # Spawns and manages the bridge process, sends JSON-lines requests,
17
+ # receives responses. Implements heartbeat (30s) and reconnect with
18
+ # exponential backoff (max 5 retries).
19
+ #
20
+ # @example
21
+ # manager = ConnectionManager.new(config: {
22
+ # 'mode' => 'direct',
23
+ # 'command' => 'bundle exec rails runner bridge.rb'
24
+ # })
25
+ # manager.connect!
26
+ # response = manager.send_request({ 'id' => 'r1', 'tool' => 'status', 'params' => {} })
27
+ # manager.disconnect!
28
+ #
29
+ class ConnectionManager
30
+ MAX_RETRIES = 5
31
+ HEARTBEAT_INTERVAL = 30
32
+
33
+ # @param config [Hash] Connection configuration
34
+ # @option config [String] 'mode' Connection mode: 'docker', 'direct', or 'ssh'
35
+ # @option config [String] 'command' Command to run the bridge
36
+ # @option config [String] 'container' Docker container name (docker mode)
37
+ # @option config [String] 'directory' Working directory (direct mode)
38
+ # @option config [String] 'host' SSH host (ssh mode)
39
+ # @option config [String] 'user' SSH user (ssh mode)
40
+ def initialize(config:)
41
+ @config = config
42
+ @mode = config['mode'] || 'direct'
43
+ @command = config['command'] || 'bundle exec rails runner lib/codebase_index/console/bridge.rb'
44
+ @stdin = nil
45
+ @stdout = nil
46
+ @wait_thread = nil
47
+ @retries = 0
48
+ @last_heartbeat = nil
49
+ end
50
+
51
+ # Spawn the bridge process.
52
+ #
53
+ # @return [void]
54
+ # @raise [ConnectionError] if the process cannot be started
55
+ def connect!
56
+ cmd = build_command
57
+ if @mode == 'direct' && @config['directory']
58
+ Dir.chdir(@config['directory']) do
59
+ @stdin, @stdout, @wait_thread = Open3.popen2(*cmd)
60
+ end
61
+ else
62
+ @stdin, @stdout, @wait_thread = Open3.popen2(*cmd)
63
+ end
64
+ @last_heartbeat = Time.now
65
+ @retries = 0
66
+ rescue StandardError => e
67
+ raise ConnectionError, "Failed to connect (#{@mode}): #{e.message}"
68
+ end
69
+
70
+ # Terminate the bridge process.
71
+ #
72
+ # @return [void]
73
+ def disconnect!
74
+ @stdin&.close
75
+ @stdout&.close
76
+ @wait_thread&.value
77
+ @stdin = nil
78
+ @stdout = nil
79
+ @wait_thread = nil
80
+ end
81
+
82
+ # Send a request to the bridge and read the response.
83
+ #
84
+ # @param request [Hash] JSON-serializable request hash
85
+ # @return [Hash] Parsed response hash
86
+ # @raise [ConnectionError] if communication fails after retries
87
+ def send_request(request)
88
+ ensure_connected!
89
+ @stdin.puts(JSON.generate(request))
90
+ @stdin.flush
91
+ line = @stdout.gets
92
+ raise ConnectionError, 'Bridge process closed unexpectedly' unless line
93
+
94
+ @last_heartbeat = Time.now
95
+ JSON.parse(line)
96
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
97
+ reconnect_or_raise!(e)
98
+ retry
99
+ end
100
+
101
+ # Check if the bridge process is alive.
102
+ #
103
+ # @return [Boolean]
104
+ def alive?
105
+ return false unless @wait_thread
106
+
107
+ @wait_thread.alive?
108
+ end
109
+
110
+ # Check if a heartbeat is needed (30s since last communication).
111
+ #
112
+ # @return [Boolean]
113
+ def heartbeat_needed?
114
+ return false unless @last_heartbeat
115
+
116
+ (Time.now - @last_heartbeat) >= HEARTBEAT_INTERVAL
117
+ end
118
+
119
+ private
120
+
121
+ # Build the shell command based on connection mode.
122
+ #
123
+ # @return [Array<String>]
124
+ def build_command
125
+ case @mode
126
+ when 'docker' then build_docker_command
127
+ when 'ssh' then build_ssh_command
128
+ when 'direct' then build_direct_command
129
+ else raise ConnectionError, "Unknown connection mode: #{@mode}"
130
+ end
131
+ end
132
+
133
+ def build_docker_command
134
+ container = @config['container'] || raise(ConnectionError, 'Docker mode requires container name')
135
+ ['docker', 'exec', '-i', container] + @command.shellsplit
136
+ end
137
+
138
+ def build_ssh_command
139
+ host = @config['host'] || raise(ConnectionError, 'SSH mode requires host')
140
+ user = @config['user']
141
+ target = user ? "#{user}@#{host}" : host
142
+ ['ssh', target] + @command.shellsplit
143
+ end
144
+
145
+ def build_direct_command
146
+ @command.shellsplit
147
+ end
148
+
149
+ # Ensure the connection is active.
150
+ def ensure_connected!
151
+ return if alive?
152
+
153
+ connect!
154
+ end
155
+
156
+ # Attempt reconnection with exponential backoff.
157
+ #
158
+ # @param error [StandardError] The original error
159
+ # @raise [ConnectionError] if max retries exceeded
160
+ def reconnect_or_raise!(error)
161
+ @retries += 1
162
+ if @retries > MAX_RETRIES
163
+ raise ConnectionError,
164
+ "Connection failed after #{MAX_RETRIES} retries: #{error.message}"
165
+ end
166
+
167
+ sleep((2**(@retries - 1)) * 0.1)
168
+ disconnect!
169
+ connect!
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../mcp/tool_response_renderer'
4
+
5
+ module CodebaseIndex
6
+ module Console
7
+ # Renders Console MCP tool responses with smart auto-detection of data shape.
8
+ #
9
+ # Auto-detects:
10
+ # - Array<Hash> → Markdown tables
11
+ # - Single Hash → Key-value bullet lists
12
+ # - Simple Array → Bullet list
13
+ # - Scalars → Plain text
14
+ #
15
+ class ConsoleResponseRenderer < MCP::ToolResponseRenderer
16
+ # Smart default: auto-detect data shape and render accordingly.
17
+ #
18
+ # @param data [Object] The bridge response result
19
+ # @return [String] Rendered text
20
+ def render_default(data)
21
+ case data
22
+ when Array
23
+ render_array(data)
24
+ when Hash
25
+ render_hash(data)
26
+ else
27
+ data.to_s
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def render_array(data)
34
+ return '_(empty)_' if data.empty?
35
+
36
+ if data.first.is_a?(Hash)
37
+ render_table(data)
38
+ else
39
+ data.map { |item| "- #{item}" }.join("\n")
40
+ end
41
+ end
42
+
43
+ def render_table(rows)
44
+ keys = rows.first.keys
45
+ lines = []
46
+ lines << "| #{keys.join(' | ')} |"
47
+ lines << "| #{keys.map { '---' }.join(' | ')} |"
48
+ rows.each do |row|
49
+ lines << "| #{keys.map { |k| row[k] }.join(' | ')} |"
50
+ end
51
+ lines.join("\n")
52
+ end
53
+
54
+ def render_hash(data)
55
+ data.map do |key, value|
56
+ case value
57
+ when Hash
58
+ "**#{key}:**\n" + value.map { |k, v| " - #{k}: #{v}" }.join("\n")
59
+ when Array
60
+ "**#{key}:** #{value.size} items"
61
+ else
62
+ "**#{key}:** #{value}"
63
+ end
64
+ end.join("\n")
65
+ end
66
+ end
67
+
68
+ # JSON passthrough renderer for backward compatibility.
69
+ # Returns JSON.pretty_generate output for any data.
70
+ class JsonConsoleRenderer < MCP::ToolResponseRenderer
71
+ # @param data [Object] Any JSON-serializable data
72
+ # @return [String] Pretty-printed JSON
73
+ def render_default(data)
74
+ JSON.pretty_generate(data)
75
+ end
76
+ end
77
+ end
78
+ end