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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/CODE_OF_CONDUCT.md +83 -0
- data/CONTRIBUTING.md +65 -0
- data/LICENSE.txt +21 -0
- data/README.md +481 -0
- data/exe/codebase-console-mcp +22 -0
- data/exe/codebase-index-mcp +61 -0
- data/exe/codebase-index-mcp-http +64 -0
- data/exe/codebase-index-mcp-start +58 -0
- data/lib/codebase_index/ast/call_site_extractor.rb +106 -0
- data/lib/codebase_index/ast/method_extractor.rb +76 -0
- data/lib/codebase_index/ast/node.rb +88 -0
- data/lib/codebase_index/ast/parser.rb +653 -0
- data/lib/codebase_index/ast.rb +6 -0
- data/lib/codebase_index/builder.rb +137 -0
- data/lib/codebase_index/chunking/chunk.rb +84 -0
- data/lib/codebase_index/chunking/semantic_chunker.rb +290 -0
- data/lib/codebase_index/console/adapters/cache_adapter.rb +58 -0
- data/lib/codebase_index/console/adapters/good_job_adapter.rb +66 -0
- data/lib/codebase_index/console/adapters/sidekiq_adapter.rb +66 -0
- data/lib/codebase_index/console/adapters/solid_queue_adapter.rb +66 -0
- data/lib/codebase_index/console/audit_logger.rb +75 -0
- data/lib/codebase_index/console/bridge.rb +170 -0
- data/lib/codebase_index/console/confirmation.rb +90 -0
- data/lib/codebase_index/console/connection_manager.rb +173 -0
- data/lib/codebase_index/console/console_response_renderer.rb +78 -0
- data/lib/codebase_index/console/model_validator.rb +81 -0
- data/lib/codebase_index/console/safe_context.rb +82 -0
- data/lib/codebase_index/console/server.rb +557 -0
- data/lib/codebase_index/console/sql_validator.rb +172 -0
- data/lib/codebase_index/console/tools/tier1.rb +118 -0
- data/lib/codebase_index/console/tools/tier2.rb +117 -0
- data/lib/codebase_index/console/tools/tier3.rb +110 -0
- data/lib/codebase_index/console/tools/tier4.rb +79 -0
- data/lib/codebase_index/coordination/pipeline_lock.rb +109 -0
- data/lib/codebase_index/cost_model/embedding_cost.rb +88 -0
- data/lib/codebase_index/cost_model/estimator.rb +128 -0
- data/lib/codebase_index/cost_model/provider_pricing.rb +67 -0
- data/lib/codebase_index/cost_model/storage_cost.rb +52 -0
- data/lib/codebase_index/cost_model.rb +22 -0
- data/lib/codebase_index/db/migrations/001_create_units.rb +38 -0
- data/lib/codebase_index/db/migrations/002_create_edges.rb +35 -0
- data/lib/codebase_index/db/migrations/003_create_embeddings.rb +37 -0
- data/lib/codebase_index/db/migrations/004_create_snapshots.rb +45 -0
- data/lib/codebase_index/db/migrations/005_create_snapshot_units.rb +40 -0
- data/lib/codebase_index/db/migrator.rb +71 -0
- data/lib/codebase_index/db/schema_version.rb +73 -0
- data/lib/codebase_index/dependency_graph.rb +227 -0
- data/lib/codebase_index/embedding/indexer.rb +130 -0
- data/lib/codebase_index/embedding/openai.rb +105 -0
- data/lib/codebase_index/embedding/provider.rb +135 -0
- data/lib/codebase_index/embedding/text_preparer.rb +112 -0
- data/lib/codebase_index/evaluation/baseline_runner.rb +115 -0
- data/lib/codebase_index/evaluation/evaluator.rb +146 -0
- data/lib/codebase_index/evaluation/metrics.rb +79 -0
- data/lib/codebase_index/evaluation/query_set.rb +148 -0
- data/lib/codebase_index/evaluation/report_generator.rb +90 -0
- data/lib/codebase_index/extracted_unit.rb +145 -0
- data/lib/codebase_index/extractor.rb +956 -0
- data/lib/codebase_index/extractors/action_cable_extractor.rb +228 -0
- data/lib/codebase_index/extractors/ast_source_extraction.rb +46 -0
- data/lib/codebase_index/extractors/behavioral_profile.rb +309 -0
- data/lib/codebase_index/extractors/caching_extractor.rb +261 -0
- data/lib/codebase_index/extractors/callback_analyzer.rb +232 -0
- data/lib/codebase_index/extractors/concern_extractor.rb +253 -0
- data/lib/codebase_index/extractors/configuration_extractor.rb +219 -0
- data/lib/codebase_index/extractors/controller_extractor.rb +494 -0
- data/lib/codebase_index/extractors/database_view_extractor.rb +278 -0
- data/lib/codebase_index/extractors/decorator_extractor.rb +260 -0
- data/lib/codebase_index/extractors/engine_extractor.rb +204 -0
- data/lib/codebase_index/extractors/event_extractor.rb +211 -0
- data/lib/codebase_index/extractors/factory_extractor.rb +289 -0
- data/lib/codebase_index/extractors/graphql_extractor.rb +917 -0
- data/lib/codebase_index/extractors/i18n_extractor.rb +117 -0
- data/lib/codebase_index/extractors/job_extractor.rb +369 -0
- data/lib/codebase_index/extractors/lib_extractor.rb +249 -0
- data/lib/codebase_index/extractors/mailer_extractor.rb +339 -0
- data/lib/codebase_index/extractors/manager_extractor.rb +202 -0
- data/lib/codebase_index/extractors/middleware_extractor.rb +133 -0
- data/lib/codebase_index/extractors/migration_extractor.rb +469 -0
- data/lib/codebase_index/extractors/model_extractor.rb +960 -0
- data/lib/codebase_index/extractors/phlex_extractor.rb +252 -0
- data/lib/codebase_index/extractors/policy_extractor.rb +214 -0
- data/lib/codebase_index/extractors/poro_extractor.rb +246 -0
- data/lib/codebase_index/extractors/pundit_extractor.rb +223 -0
- data/lib/codebase_index/extractors/rails_source_extractor.rb +473 -0
- data/lib/codebase_index/extractors/rake_task_extractor.rb +343 -0
- data/lib/codebase_index/extractors/route_extractor.rb +181 -0
- data/lib/codebase_index/extractors/scheduled_job_extractor.rb +331 -0
- data/lib/codebase_index/extractors/serializer_extractor.rb +334 -0
- data/lib/codebase_index/extractors/service_extractor.rb +254 -0
- data/lib/codebase_index/extractors/shared_dependency_scanner.rb +91 -0
- data/lib/codebase_index/extractors/shared_utility_methods.rb +99 -0
- data/lib/codebase_index/extractors/state_machine_extractor.rb +398 -0
- data/lib/codebase_index/extractors/test_mapping_extractor.rb +225 -0
- data/lib/codebase_index/extractors/validator_extractor.rb +225 -0
- data/lib/codebase_index/extractors/view_component_extractor.rb +310 -0
- data/lib/codebase_index/extractors/view_template_extractor.rb +261 -0
- data/lib/codebase_index/feedback/gap_detector.rb +89 -0
- data/lib/codebase_index/feedback/store.rb +119 -0
- data/lib/codebase_index/flow_analysis/operation_extractor.rb +209 -0
- data/lib/codebase_index/flow_analysis/response_code_mapper.rb +154 -0
- data/lib/codebase_index/flow_assembler.rb +290 -0
- data/lib/codebase_index/flow_document.rb +191 -0
- data/lib/codebase_index/flow_precomputer.rb +102 -0
- data/lib/codebase_index/formatting/base.rb +40 -0
- data/lib/codebase_index/formatting/claude_adapter.rb +98 -0
- data/lib/codebase_index/formatting/generic_adapter.rb +56 -0
- data/lib/codebase_index/formatting/gpt_adapter.rb +64 -0
- data/lib/codebase_index/formatting/human_adapter.rb +78 -0
- data/lib/codebase_index/graph_analyzer.rb +374 -0
- data/lib/codebase_index/mcp/index_reader.rb +394 -0
- data/lib/codebase_index/mcp/renderers/claude_renderer.rb +81 -0
- data/lib/codebase_index/mcp/renderers/json_renderer.rb +17 -0
- data/lib/codebase_index/mcp/renderers/markdown_renderer.rb +352 -0
- data/lib/codebase_index/mcp/renderers/plain_renderer.rb +240 -0
- data/lib/codebase_index/mcp/server.rb +935 -0
- data/lib/codebase_index/mcp/tool_response_renderer.rb +62 -0
- data/lib/codebase_index/model_name_cache.rb +51 -0
- data/lib/codebase_index/notion/client.rb +217 -0
- data/lib/codebase_index/notion/exporter.rb +219 -0
- data/lib/codebase_index/notion/mapper.rb +39 -0
- data/lib/codebase_index/notion/mappers/column_mapper.rb +65 -0
- data/lib/codebase_index/notion/mappers/migration_mapper.rb +39 -0
- data/lib/codebase_index/notion/mappers/model_mapper.rb +164 -0
- data/lib/codebase_index/notion/rate_limiter.rb +68 -0
- data/lib/codebase_index/observability/health_check.rb +81 -0
- data/lib/codebase_index/observability/instrumentation.rb +34 -0
- data/lib/codebase_index/observability/structured_logger.rb +75 -0
- data/lib/codebase_index/operator/error_escalator.rb +81 -0
- data/lib/codebase_index/operator/pipeline_guard.rb +99 -0
- data/lib/codebase_index/operator/status_reporter.rb +80 -0
- data/lib/codebase_index/railtie.rb +26 -0
- data/lib/codebase_index/resilience/circuit_breaker.rb +99 -0
- data/lib/codebase_index/resilience/index_validator.rb +185 -0
- data/lib/codebase_index/resilience/retryable_provider.rb +108 -0
- data/lib/codebase_index/retrieval/context_assembler.rb +249 -0
- data/lib/codebase_index/retrieval/query_classifier.rb +131 -0
- data/lib/codebase_index/retrieval/ranker.rb +273 -0
- data/lib/codebase_index/retrieval/search_executor.rb +327 -0
- data/lib/codebase_index/retriever.rb +160 -0
- data/lib/codebase_index/ruby_analyzer/class_analyzer.rb +190 -0
- data/lib/codebase_index/ruby_analyzer/dataflow_analyzer.rb +78 -0
- data/lib/codebase_index/ruby_analyzer/fqn_builder.rb +18 -0
- data/lib/codebase_index/ruby_analyzer/mermaid_renderer.rb +275 -0
- data/lib/codebase_index/ruby_analyzer/method_analyzer.rb +143 -0
- data/lib/codebase_index/ruby_analyzer/trace_enricher.rb +139 -0
- data/lib/codebase_index/ruby_analyzer.rb +87 -0
- data/lib/codebase_index/session_tracer/file_store.rb +111 -0
- data/lib/codebase_index/session_tracer/middleware.rb +143 -0
- data/lib/codebase_index/session_tracer/redis_store.rb +112 -0
- data/lib/codebase_index/session_tracer/session_flow_assembler.rb +263 -0
- data/lib/codebase_index/session_tracer/session_flow_document.rb +223 -0
- data/lib/codebase_index/session_tracer/solid_cache_store.rb +145 -0
- data/lib/codebase_index/session_tracer/store.rb +67 -0
- data/lib/codebase_index/storage/graph_store.rb +120 -0
- data/lib/codebase_index/storage/metadata_store.rb +169 -0
- data/lib/codebase_index/storage/pgvector.rb +163 -0
- data/lib/codebase_index/storage/qdrant.rb +172 -0
- data/lib/codebase_index/storage/vector_store.rb +156 -0
- data/lib/codebase_index/temporal/snapshot_store.rb +341 -0
- data/lib/codebase_index/version.rb +5 -0
- data/lib/codebase_index.rb +223 -0
- data/lib/generators/codebase_index/install_generator.rb +32 -0
- data/lib/generators/codebase_index/pgvector_generator.rb +37 -0
- data/lib/generators/codebase_index/templates/add_pgvector_to_codebase_index.rb.erb +15 -0
- data/lib/generators/codebase_index/templates/create_codebase_index_tables.rb.erb +43 -0
- data/lib/tasks/codebase_index.rake +583 -0
- data/lib/tasks/codebase_index_evaluation.rake +115 -0
- 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
|