woods 1.0.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 +89 -0
- data/CODE_OF_CONDUCT.md +83 -0
- data/CONTRIBUTING.md +65 -0
- data/LICENSE.txt +21 -0
- data/README.md +406 -0
- data/exe/woods-console +59 -0
- data/exe/woods-console-mcp +22 -0
- data/exe/woods-mcp +34 -0
- data/exe/woods-mcp-http +37 -0
- data/exe/woods-mcp-start +58 -0
- data/lib/generators/woods/install_generator.rb +32 -0
- data/lib/generators/woods/pgvector_generator.rb +37 -0
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +15 -0
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +43 -0
- data/lib/tasks/woods.rake +621 -0
- data/lib/tasks/woods_evaluation.rake +115 -0
- data/lib/woods/ast/call_site_extractor.rb +106 -0
- data/lib/woods/ast/method_extractor.rb +71 -0
- data/lib/woods/ast/node.rb +116 -0
- data/lib/woods/ast/parser.rb +614 -0
- data/lib/woods/ast.rb +6 -0
- data/lib/woods/builder.rb +200 -0
- data/lib/woods/cache/cache_middleware.rb +199 -0
- data/lib/woods/cache/cache_store.rb +264 -0
- data/lib/woods/cache/redis_cache_store.rb +116 -0
- data/lib/woods/cache/solid_cache_store.rb +111 -0
- data/lib/woods/chunking/chunk.rb +84 -0
- data/lib/woods/chunking/semantic_chunker.rb +295 -0
- data/lib/woods/console/adapters/cache_adapter.rb +58 -0
- data/lib/woods/console/adapters/good_job_adapter.rb +33 -0
- data/lib/woods/console/adapters/job_adapter.rb +68 -0
- data/lib/woods/console/adapters/sidekiq_adapter.rb +33 -0
- data/lib/woods/console/adapters/solid_queue_adapter.rb +33 -0
- data/lib/woods/console/audit_logger.rb +75 -0
- data/lib/woods/console/bridge.rb +177 -0
- data/lib/woods/console/confirmation.rb +90 -0
- data/lib/woods/console/connection_manager.rb +173 -0
- data/lib/woods/console/console_response_renderer.rb +74 -0
- data/lib/woods/console/embedded_executor.rb +373 -0
- data/lib/woods/console/model_validator.rb +81 -0
- data/lib/woods/console/rack_middleware.rb +87 -0
- data/lib/woods/console/safe_context.rb +82 -0
- data/lib/woods/console/server.rb +612 -0
- data/lib/woods/console/sql_validator.rb +172 -0
- data/lib/woods/console/tools/tier1.rb +118 -0
- data/lib/woods/console/tools/tier2.rb +117 -0
- data/lib/woods/console/tools/tier3.rb +110 -0
- data/lib/woods/console/tools/tier4.rb +79 -0
- data/lib/woods/coordination/pipeline_lock.rb +109 -0
- data/lib/woods/cost_model/embedding_cost.rb +88 -0
- data/lib/woods/cost_model/estimator.rb +128 -0
- data/lib/woods/cost_model/provider_pricing.rb +67 -0
- data/lib/woods/cost_model/storage_cost.rb +52 -0
- data/lib/woods/cost_model.rb +22 -0
- data/lib/woods/db/migrations/001_create_units.rb +38 -0
- data/lib/woods/db/migrations/002_create_edges.rb +35 -0
- data/lib/woods/db/migrations/003_create_embeddings.rb +37 -0
- data/lib/woods/db/migrations/004_create_snapshots.rb +45 -0
- data/lib/woods/db/migrations/005_create_snapshot_units.rb +40 -0
- data/lib/woods/db/migrations/006_rename_tables.rb +34 -0
- data/lib/woods/db/migrator.rb +73 -0
- data/lib/woods/db/schema_version.rb +73 -0
- data/lib/woods/dependency_graph.rb +236 -0
- data/lib/woods/embedding/indexer.rb +140 -0
- data/lib/woods/embedding/openai.rb +126 -0
- data/lib/woods/embedding/provider.rb +162 -0
- data/lib/woods/embedding/text_preparer.rb +112 -0
- data/lib/woods/evaluation/baseline_runner.rb +115 -0
- data/lib/woods/evaluation/evaluator.rb +139 -0
- data/lib/woods/evaluation/metrics.rb +79 -0
- data/lib/woods/evaluation/query_set.rb +148 -0
- data/lib/woods/evaluation/report_generator.rb +90 -0
- data/lib/woods/extracted_unit.rb +145 -0
- data/lib/woods/extractor.rb +1028 -0
- data/lib/woods/extractors/action_cable_extractor.rb +201 -0
- data/lib/woods/extractors/ast_source_extraction.rb +46 -0
- data/lib/woods/extractors/behavioral_profile.rb +309 -0
- data/lib/woods/extractors/caching_extractor.rb +261 -0
- data/lib/woods/extractors/callback_analyzer.rb +246 -0
- data/lib/woods/extractors/concern_extractor.rb +292 -0
- data/lib/woods/extractors/configuration_extractor.rb +219 -0
- data/lib/woods/extractors/controller_extractor.rb +404 -0
- data/lib/woods/extractors/database_view_extractor.rb +278 -0
- data/lib/woods/extractors/decorator_extractor.rb +253 -0
- data/lib/woods/extractors/engine_extractor.rb +223 -0
- data/lib/woods/extractors/event_extractor.rb +211 -0
- data/lib/woods/extractors/factory_extractor.rb +289 -0
- data/lib/woods/extractors/graphql_extractor.rb +892 -0
- data/lib/woods/extractors/i18n_extractor.rb +117 -0
- data/lib/woods/extractors/job_extractor.rb +374 -0
- data/lib/woods/extractors/lib_extractor.rb +218 -0
- data/lib/woods/extractors/mailer_extractor.rb +269 -0
- data/lib/woods/extractors/manager_extractor.rb +188 -0
- data/lib/woods/extractors/middleware_extractor.rb +133 -0
- data/lib/woods/extractors/migration_extractor.rb +469 -0
- data/lib/woods/extractors/model_extractor.rb +988 -0
- data/lib/woods/extractors/phlex_extractor.rb +252 -0
- data/lib/woods/extractors/policy_extractor.rb +191 -0
- data/lib/woods/extractors/poro_extractor.rb +229 -0
- data/lib/woods/extractors/pundit_extractor.rb +223 -0
- data/lib/woods/extractors/rails_source_extractor.rb +473 -0
- data/lib/woods/extractors/rake_task_extractor.rb +343 -0
- data/lib/woods/extractors/route_extractor.rb +181 -0
- data/lib/woods/extractors/scheduled_job_extractor.rb +331 -0
- data/lib/woods/extractors/serializer_extractor.rb +339 -0
- data/lib/woods/extractors/service_extractor.rb +217 -0
- data/lib/woods/extractors/shared_dependency_scanner.rb +91 -0
- data/lib/woods/extractors/shared_utility_methods.rb +281 -0
- data/lib/woods/extractors/state_machine_extractor.rb +398 -0
- data/lib/woods/extractors/test_mapping_extractor.rb +225 -0
- data/lib/woods/extractors/validator_extractor.rb +211 -0
- data/lib/woods/extractors/view_component_extractor.rb +311 -0
- data/lib/woods/extractors/view_template_extractor.rb +261 -0
- data/lib/woods/feedback/gap_detector.rb +89 -0
- data/lib/woods/feedback/store.rb +119 -0
- data/lib/woods/filename_utils.rb +32 -0
- data/lib/woods/flow_analysis/operation_extractor.rb +206 -0
- data/lib/woods/flow_analysis/response_code_mapper.rb +154 -0
- data/lib/woods/flow_assembler.rb +290 -0
- data/lib/woods/flow_document.rb +191 -0
- data/lib/woods/flow_precomputer.rb +102 -0
- data/lib/woods/formatting/base.rb +30 -0
- data/lib/woods/formatting/claude_adapter.rb +98 -0
- data/lib/woods/formatting/generic_adapter.rb +56 -0
- data/lib/woods/formatting/gpt_adapter.rb +64 -0
- data/lib/woods/formatting/human_adapter.rb +78 -0
- data/lib/woods/graph_analyzer.rb +374 -0
- data/lib/woods/mcp/bootstrapper.rb +96 -0
- data/lib/woods/mcp/index_reader.rb +394 -0
- data/lib/woods/mcp/renderers/claude_renderer.rb +81 -0
- data/lib/woods/mcp/renderers/json_renderer.rb +17 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +353 -0
- data/lib/woods/mcp/renderers/plain_renderer.rb +240 -0
- data/lib/woods/mcp/server.rb +962 -0
- data/lib/woods/mcp/tool_response_renderer.rb +85 -0
- data/lib/woods/model_name_cache.rb +51 -0
- data/lib/woods/notion/client.rb +217 -0
- data/lib/woods/notion/exporter.rb +219 -0
- data/lib/woods/notion/mapper.rb +40 -0
- data/lib/woods/notion/mappers/column_mapper.rb +57 -0
- data/lib/woods/notion/mappers/migration_mapper.rb +39 -0
- data/lib/woods/notion/mappers/model_mapper.rb +161 -0
- data/lib/woods/notion/mappers/shared.rb +22 -0
- data/lib/woods/notion/rate_limiter.rb +68 -0
- data/lib/woods/observability/health_check.rb +79 -0
- data/lib/woods/observability/instrumentation.rb +34 -0
- data/lib/woods/observability/structured_logger.rb +57 -0
- data/lib/woods/operator/error_escalator.rb +81 -0
- data/lib/woods/operator/pipeline_guard.rb +92 -0
- data/lib/woods/operator/status_reporter.rb +80 -0
- data/lib/woods/railtie.rb +38 -0
- data/lib/woods/resilience/circuit_breaker.rb +99 -0
- data/lib/woods/resilience/index_validator.rb +167 -0
- data/lib/woods/resilience/retryable_provider.rb +108 -0
- data/lib/woods/retrieval/context_assembler.rb +261 -0
- data/lib/woods/retrieval/query_classifier.rb +133 -0
- data/lib/woods/retrieval/ranker.rb +277 -0
- data/lib/woods/retrieval/search_executor.rb +316 -0
- data/lib/woods/retriever.rb +152 -0
- data/lib/woods/ruby_analyzer/class_analyzer.rb +170 -0
- data/lib/woods/ruby_analyzer/dataflow_analyzer.rb +77 -0
- data/lib/woods/ruby_analyzer/fqn_builder.rb +18 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +280 -0
- data/lib/woods/ruby_analyzer/method_analyzer.rb +143 -0
- data/lib/woods/ruby_analyzer/trace_enricher.rb +143 -0
- data/lib/woods/ruby_analyzer.rb +87 -0
- data/lib/woods/session_tracer/file_store.rb +104 -0
- data/lib/woods/session_tracer/middleware.rb +143 -0
- data/lib/woods/session_tracer/redis_store.rb +106 -0
- data/lib/woods/session_tracer/session_flow_assembler.rb +254 -0
- data/lib/woods/session_tracer/session_flow_document.rb +223 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +139 -0
- data/lib/woods/session_tracer/store.rb +81 -0
- data/lib/woods/storage/graph_store.rb +120 -0
- data/lib/woods/storage/metadata_store.rb +196 -0
- data/lib/woods/storage/pgvector.rb +195 -0
- data/lib/woods/storage/qdrant.rb +205 -0
- data/lib/woods/storage/vector_store.rb +167 -0
- data/lib/woods/temporal/json_snapshot_store.rb +245 -0
- data/lib/woods/temporal/snapshot_store.rb +345 -0
- data/lib/woods/token_utils.rb +19 -0
- data/lib/woods/version.rb +5 -0
- data/lib/woods.rb +246 -0
- metadata +270 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../mcp/tool_response_renderer'
|
|
4
|
+
require_relative '../mcp/renderers/json_renderer'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Console
|
|
8
|
+
# Renders Console MCP tool responses with smart auto-detection of data shape.
|
|
9
|
+
#
|
|
10
|
+
# Auto-detects:
|
|
11
|
+
# - Array<Hash> → Markdown tables
|
|
12
|
+
# - Single Hash → Key-value bullet lists
|
|
13
|
+
# - Simple Array → Bullet list
|
|
14
|
+
# - Scalars → Plain text
|
|
15
|
+
#
|
|
16
|
+
class ConsoleResponseRenderer < MCP::ToolResponseRenderer
|
|
17
|
+
# Smart default: auto-detect data shape and render accordingly.
|
|
18
|
+
#
|
|
19
|
+
# @param data [Object] The bridge response result
|
|
20
|
+
# @return [String] Rendered text
|
|
21
|
+
def render_default(data)
|
|
22
|
+
case data
|
|
23
|
+
when Array
|
|
24
|
+
render_array(data)
|
|
25
|
+
when Hash
|
|
26
|
+
render_hash(data)
|
|
27
|
+
else
|
|
28
|
+
data.to_s
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def render_array(data)
|
|
35
|
+
return '_(empty)_' if data.empty?
|
|
36
|
+
|
|
37
|
+
if data.first.is_a?(Hash)
|
|
38
|
+
render_table(data)
|
|
39
|
+
else
|
|
40
|
+
data.map { |item| "- #{item}" }.join("\n")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def render_table(rows)
|
|
45
|
+
keys = rows.first.keys
|
|
46
|
+
lines = []
|
|
47
|
+
lines << "| #{keys.join(' | ')} |"
|
|
48
|
+
lines << "| #{keys.map { '---' }.join(' | ')} |"
|
|
49
|
+
rows.each do |row|
|
|
50
|
+
lines << "| #{keys.map { |k| row[k] }.join(' | ')} |"
|
|
51
|
+
end
|
|
52
|
+
lines.join("\n")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def render_hash(data)
|
|
56
|
+
data.map do |key, value|
|
|
57
|
+
case value
|
|
58
|
+
when Hash
|
|
59
|
+
"**#{key}:**\n" + value.map { |k, v| " - #{k}: #{v}" }.join("\n")
|
|
60
|
+
when Array
|
|
61
|
+
"**#{key}:** #{value.size} items"
|
|
62
|
+
else
|
|
63
|
+
"**#{key}:** #{value}"
|
|
64
|
+
end
|
|
65
|
+
end.join("\n")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# JSON passthrough renderer for backward compatibility.
|
|
70
|
+
# Delegates to MCP::Renderers::JsonRenderer for consistent JSON output.
|
|
71
|
+
class JsonConsoleRenderer < MCP::Renderers::JsonRenderer
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'bridge'
|
|
4
|
+
require_relative 'model_validator'
|
|
5
|
+
require_relative 'safe_context'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module Console
|
|
9
|
+
# Drop-in replacement for ConnectionManager + Bridge that executes
|
|
10
|
+
# queries directly via ActiveRecord instead of a separate bridge process.
|
|
11
|
+
#
|
|
12
|
+
# Implements the same `send_request(Hash) -> Hash` interface as
|
|
13
|
+
# ConnectionManager, so all existing tool definitions in Server work
|
|
14
|
+
# unchanged — just pass this where `conn_mgr` goes.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# executor = EmbeddedExecutor.new(model_validator: validator, safe_context: ctx)
|
|
18
|
+
# response = executor.send_request({ 'tool' => 'count', 'params' => { 'model' => 'User' } })
|
|
19
|
+
# # => { 'ok' => true, 'result' => { 'count' => 42 }, 'timing_ms' => 1.2 }
|
|
20
|
+
#
|
|
21
|
+
class EmbeddedExecutor # rubocop:disable Metrics/ClassLength
|
|
22
|
+
AGGREGATE_FUNCTIONS = %w[sum average minimum maximum].freeze
|
|
23
|
+
|
|
24
|
+
TIER1_TOOLS = Bridge::TIER1_TOOLS
|
|
25
|
+
|
|
26
|
+
# Tools gated behind the read_tools_enabled flag.
|
|
27
|
+
# sql/query have existing safety gates (SqlValidator, SafeContext rollback)
|
|
28
|
+
# but require explicit opt-in for embedded mode.
|
|
29
|
+
EMBEDDED_READ_TOOLS = %w[sql query].freeze
|
|
30
|
+
|
|
31
|
+
MAX_SQL_LIMIT = 10_000
|
|
32
|
+
MAX_QUERY_LIMIT = 10_000
|
|
33
|
+
|
|
34
|
+
# @param model_validator [ModelValidator] Validates model/column names
|
|
35
|
+
# @param safe_context [SafeContext] Wraps execution in rolled-back transaction
|
|
36
|
+
# @param connection [Object, nil] Database connection for adapter detection
|
|
37
|
+
# @param read_tools_enabled [Boolean] Enable sql/query tools in embedded mode (default: false)
|
|
38
|
+
def initialize(model_validator:, safe_context:, connection: nil, read_tools_enabled: false)
|
|
39
|
+
@model_validator = model_validator
|
|
40
|
+
@safe_context = safe_context
|
|
41
|
+
@connection = connection
|
|
42
|
+
@read_tools_enabled = read_tools_enabled
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Execute a tool request and return a response hash.
|
|
46
|
+
#
|
|
47
|
+
# Compatible with ConnectionManager#send_request — Server's `send_to_bridge`
|
|
48
|
+
# calls this method and expects `{ 'ok' => true/false, ... }`.
|
|
49
|
+
#
|
|
50
|
+
# @param request [Hash] Request with 'tool' and 'params' keys
|
|
51
|
+
# @return [Hash] Response with 'ok', 'result'/'error', and 'timing_ms'
|
|
52
|
+
def send_request(request)
|
|
53
|
+
# Deep-stringify keys — Tier1 tool builders use symbol keys, but the bridge
|
|
54
|
+
# path naturally stringifies via JSON round-trip. Replicate that here.
|
|
55
|
+
request = deep_stringify_keys(request)
|
|
56
|
+
tool = request['tool']
|
|
57
|
+
params = request['params'] || {}
|
|
58
|
+
|
|
59
|
+
unless TIER1_TOOLS.include?(tool) || (@read_tools_enabled && EMBEDDED_READ_TOOLS.include?(tool))
|
|
60
|
+
return { 'ok' => false,
|
|
61
|
+
'error' => 'Not yet implemented in embedded mode',
|
|
62
|
+
'error_type' => 'unsupported' }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
+
result = @safe_context.execute { dispatch(tool, params) }
|
|
67
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
|
|
68
|
+
|
|
69
|
+
{ 'ok' => true, 'result' => result, 'timing_ms' => elapsed }
|
|
70
|
+
rescue ValidationError => e
|
|
71
|
+
{ 'ok' => false, 'error' => e.message, 'error_type' => 'validation' }
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
{ 'ok' => false, 'error' => e.message, 'error_type' => 'execution' }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Route a tool name to its handler.
|
|
79
|
+
#
|
|
80
|
+
# @param tool [String] Tool name
|
|
81
|
+
# @param params [Hash] Tool parameters
|
|
82
|
+
# @return [Hash] Tool result
|
|
83
|
+
def dispatch(tool, params)
|
|
84
|
+
case tool
|
|
85
|
+
when 'status' then handle_status
|
|
86
|
+
when 'schema' then handle_schema(params)
|
|
87
|
+
when 'sql' then handle_sql(params)
|
|
88
|
+
when 'query' then handle_query(params)
|
|
89
|
+
else
|
|
90
|
+
validate_model!(params)
|
|
91
|
+
send(:"handle_#{tool}", params)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param params [Hash] Must contain 'model' key
|
|
96
|
+
# @raise [ValidationError]
|
|
97
|
+
def validate_model!(params)
|
|
98
|
+
model = params['model']
|
|
99
|
+
raise ValidationError, 'Missing required parameter: model' unless model
|
|
100
|
+
|
|
101
|
+
@model_validator.validate_model!(model)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Resolve a model name string to an ActiveRecord class.
|
|
105
|
+
#
|
|
106
|
+
# @param name [String] Model class name (e.g., 'User', 'Admin::Account')
|
|
107
|
+
# @return [Class] The ActiveRecord model class
|
|
108
|
+
def resolve_model(name)
|
|
109
|
+
name.constantize
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ── Tier 1 Handlers ──────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def handle_count(params)
|
|
115
|
+
model = resolve_model(params['model'])
|
|
116
|
+
scope = apply_scope(model, params['scope'])
|
|
117
|
+
{ 'count' => scope.count }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def handle_sample(params)
|
|
121
|
+
model = resolve_model(params['model'])
|
|
122
|
+
limit = [params.fetch('limit', 5).to_i, 25].min
|
|
123
|
+
scope = apply_scope(model, params['scope'])
|
|
124
|
+
scope = apply_columns(scope, params['columns'])
|
|
125
|
+
records = scope.order(random_function).limit(limit)
|
|
126
|
+
{ 'records' => serialize_records(records, params['columns']) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def handle_find(params)
|
|
130
|
+
model = resolve_model(params['model'])
|
|
131
|
+
record = if params['id']
|
|
132
|
+
model.find_by(id: params['id'])
|
|
133
|
+
elsif params['by']
|
|
134
|
+
model.find_by(params['by'])
|
|
135
|
+
end
|
|
136
|
+
{ 'record' => record ? serialize_record(record, params['columns']) : nil }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def handle_pluck(params)
|
|
140
|
+
columns = params['columns']
|
|
141
|
+
@model_validator.validate_columns!(params['model'], columns) if columns
|
|
142
|
+
model = resolve_model(params['model'])
|
|
143
|
+
limit = [params.fetch('limit', 100).to_i, 1000].min
|
|
144
|
+
scope = apply_scope(model, params['scope'])
|
|
145
|
+
scope = scope.distinct if params['distinct']
|
|
146
|
+
values = scope.limit(limit).pluck(*columns.map(&:to_sym))
|
|
147
|
+
{ 'values' => values }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def handle_aggregate(params)
|
|
151
|
+
column = params['column']
|
|
152
|
+
function = params['function']
|
|
153
|
+
@model_validator.validate_column!(params['model'], column) if column
|
|
154
|
+
|
|
155
|
+
unless AGGREGATE_FUNCTIONS.include?(function)
|
|
156
|
+
raise ValidationError, "Invalid aggregate function: #{function}. " \
|
|
157
|
+
"Allowed: #{AGGREGATE_FUNCTIONS.join(', ')}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
model = resolve_model(params['model'])
|
|
161
|
+
scope = apply_scope(model, params['scope'])
|
|
162
|
+
{ 'value' => scope.send(function.to_sym, column.to_sym) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def handle_association_count(params)
|
|
166
|
+
model = resolve_model(params['model'])
|
|
167
|
+
record = model.find(params['id'])
|
|
168
|
+
association_name = params['association']
|
|
169
|
+
|
|
170
|
+
unless model.reflect_on_association(association_name.to_sym)
|
|
171
|
+
raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
scope = record.public_send(association_name)
|
|
175
|
+
scope = apply_scope(scope, params['scope'])
|
|
176
|
+
{ 'count' => scope.count }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def handle_schema(params)
|
|
180
|
+
model_name = params['model']
|
|
181
|
+
raise ValidationError, 'Missing required parameter: model' unless model_name
|
|
182
|
+
|
|
183
|
+
@model_validator.validate_model!(model_name)
|
|
184
|
+
model = resolve_model(model_name)
|
|
185
|
+
|
|
186
|
+
columns = model.columns_hash.transform_values do |col|
|
|
187
|
+
{ 'type' => col.type.to_s, 'null' => col.null, 'default' => col.default&.to_s }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
result = { 'columns' => columns }
|
|
191
|
+
|
|
192
|
+
if params['include_indexes']
|
|
193
|
+
indexes = model.connection.indexes(model.table_name).map do |idx|
|
|
194
|
+
{ 'name' => idx.name, 'columns' => idx.columns, 'unique' => idx.unique }
|
|
195
|
+
end
|
|
196
|
+
result['indexes'] = indexes
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
result
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def handle_recent(params)
|
|
203
|
+
model = resolve_model(params['model'])
|
|
204
|
+
order_by = params.fetch('order_by', 'created_at')
|
|
205
|
+
direction = params.fetch('direction', 'desc')
|
|
206
|
+
limit = [params.fetch('limit', 10).to_i, 50].min
|
|
207
|
+
|
|
208
|
+
@model_validator.validate_column!(params['model'], order_by)
|
|
209
|
+
direction = 'desc' unless %w[asc desc].include?(direction)
|
|
210
|
+
|
|
211
|
+
scope = apply_scope(model, params['scope'])
|
|
212
|
+
scope = apply_columns(scope, params['columns'])
|
|
213
|
+
records = scope.order(order_by => direction.to_sym).limit(limit)
|
|
214
|
+
{ 'records' => serialize_records(records, params['columns']) }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def handle_status
|
|
218
|
+
adapter = begin
|
|
219
|
+
active_connection.adapter_name
|
|
220
|
+
rescue StandardError
|
|
221
|
+
'unknown'
|
|
222
|
+
end
|
|
223
|
+
{ 'status' => 'ok', 'models' => @model_validator.model_names, 'adapter' => adapter }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# ── Read tools (sql/query, gated by read_tools_enabled) ────────────
|
|
227
|
+
|
|
228
|
+
# Execute validated read-only SQL via ActiveRecord's select_all.
|
|
229
|
+
#
|
|
230
|
+
# @param params [Hash] Must contain 'sql'; optional 'limit'
|
|
231
|
+
# @return [Hash] Columns and rows
|
|
232
|
+
def handle_sql(params)
|
|
233
|
+
sql = params['sql']
|
|
234
|
+
raise ValidationError, 'Missing required parameter: sql' unless sql
|
|
235
|
+
|
|
236
|
+
require_relative 'sql_validator'
|
|
237
|
+
SqlValidator.new.validate!(sql)
|
|
238
|
+
|
|
239
|
+
limit = params['limit'] ? [params['limit'].to_i, MAX_SQL_LIMIT].min : nil
|
|
240
|
+
query_sql = limit ? "SELECT * FROM (#{sql}) AS _limited LIMIT #{limit}" : sql
|
|
241
|
+
result = active_connection.select_all(query_sql)
|
|
242
|
+
|
|
243
|
+
{ 'columns' => result.columns, 'rows' => result.rows, 'count' => result.rows.size }
|
|
244
|
+
rescue SqlValidationError => e
|
|
245
|
+
raise ValidationError, e.message
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Build and execute a structured ActiveRecord query.
|
|
249
|
+
#
|
|
250
|
+
# @param params [Hash] Must contain 'model' and 'select'
|
|
251
|
+
# @return [Hash] Columns and rows
|
|
252
|
+
def handle_query(params)
|
|
253
|
+
validate_model!(params)
|
|
254
|
+
model = resolve_model(params['model'])
|
|
255
|
+
relation = build_query_relation(model, params)
|
|
256
|
+
result = active_connection.select_all(relation.to_sql)
|
|
257
|
+
{ 'columns' => result.columns, 'rows' => result.rows, 'count' => result.rows.size }
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Build an ActiveRecord relation from structured query parameters.
|
|
261
|
+
#
|
|
262
|
+
# @param model [Class] ActiveRecord model class
|
|
263
|
+
# @param params [Hash] Query parameters (select, joins, scope, group_by, having, order, limit)
|
|
264
|
+
# @return [ActiveRecord::Relation]
|
|
265
|
+
def build_query_relation(model, params)
|
|
266
|
+
relation = apply_query_clauses(model.all, params)
|
|
267
|
+
limit = params['limit'] ? [params['limit'].to_i, MAX_QUERY_LIMIT].min : MAX_QUERY_LIMIT
|
|
268
|
+
relation.limit(limit)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Apply select/joins/scope/group/having/order clauses to a relation.
|
|
272
|
+
#
|
|
273
|
+
# @param relation [ActiveRecord::Relation]
|
|
274
|
+
# @param params [Hash]
|
|
275
|
+
# @return [ActiveRecord::Relation]
|
|
276
|
+
def apply_query_clauses(relation, params) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
277
|
+
relation = relation.select(params['select']) if params['select']
|
|
278
|
+
relation = relation.joins(params['joins'].map(&:to_sym)) if params['joins']&.any?
|
|
279
|
+
relation = apply_scope(relation, params['scope'])
|
|
280
|
+
relation = relation.group(params['group_by']) if params['group_by']&.any?
|
|
281
|
+
relation = relation.having(params['having']) if params['having']
|
|
282
|
+
relation = relation.order(params['order']) if params['order']
|
|
283
|
+
relation
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
# Apply scope conditions (WHERE clauses) to a relation.
|
|
289
|
+
#
|
|
290
|
+
# Accepts Hash form for simple equality conditions, or Array form
|
|
291
|
+
# for parameterized SQL (e.g., JSON column queries like
|
|
292
|
+
# ["preferences->>'theme' = ?", "dark"]).
|
|
293
|
+
#
|
|
294
|
+
# @param relation [ActiveRecord::Relation, Class] Model or relation
|
|
295
|
+
# @param scope [Hash, Array, nil] Filter conditions
|
|
296
|
+
# @return [ActiveRecord::Relation]
|
|
297
|
+
def apply_scope(relation, scope)
|
|
298
|
+
case scope
|
|
299
|
+
when Hash
|
|
300
|
+
scope.any? ? relation.where(scope) : relation
|
|
301
|
+
when Array
|
|
302
|
+
scope.any? ? relation.where(*scope) : relation
|
|
303
|
+
else
|
|
304
|
+
relation
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Apply column selection to a relation.
|
|
309
|
+
#
|
|
310
|
+
# @param relation [ActiveRecord::Relation] The relation
|
|
311
|
+
# @param columns [Array<String>, nil] Columns to select
|
|
312
|
+
# @return [ActiveRecord::Relation]
|
|
313
|
+
def apply_columns(relation, columns)
|
|
314
|
+
return relation unless columns.is_a?(Array) && columns.any?
|
|
315
|
+
|
|
316
|
+
relation.select(columns)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Serialize a single record to a Hash.
|
|
320
|
+
#
|
|
321
|
+
# @param record [ActiveRecord::Base] The record
|
|
322
|
+
# @param columns [Array<String>, nil] Columns to include
|
|
323
|
+
# @return [Hash]
|
|
324
|
+
def serialize_record(record, columns = nil)
|
|
325
|
+
if columns.is_a?(Array) && columns.any?
|
|
326
|
+
record.attributes.slice(*columns)
|
|
327
|
+
else
|
|
328
|
+
record.attributes
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Serialize multiple records.
|
|
333
|
+
#
|
|
334
|
+
# @param records [ActiveRecord::Relation] The records
|
|
335
|
+
# @param columns [Array<String>, nil] Columns to include
|
|
336
|
+
# @return [Array<Hash>]
|
|
337
|
+
def serialize_records(records, columns = nil)
|
|
338
|
+
records.map { |r| serialize_record(r, columns) }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# DB-dialect-aware random ordering function.
|
|
342
|
+
#
|
|
343
|
+
# @return [Arel::Nodes::SqlLiteral]
|
|
344
|
+
def random_function
|
|
345
|
+
adapter = active_connection.adapter_name.downcase
|
|
346
|
+
func = adapter.include?('mysql') ? 'RAND' : 'RANDOM'
|
|
347
|
+
Arel.sql("#{func}()")
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Return the database connection (injected or from ActiveRecord).
|
|
351
|
+
#
|
|
352
|
+
# @return [Object] Database connection
|
|
353
|
+
def active_connection
|
|
354
|
+
@connection || ActiveRecord::Base.connection
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Recursively convert all Hash keys to strings.
|
|
358
|
+
#
|
|
359
|
+
# @param obj [Object] The object to stringify
|
|
360
|
+
# @return [Object] Object with string keys
|
|
361
|
+
def deep_stringify_keys(obj)
|
|
362
|
+
case obj
|
|
363
|
+
when Hash
|
|
364
|
+
obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
|
|
365
|
+
when Array
|
|
366
|
+
obj.map { |item| deep_stringify_keys(item) }
|
|
367
|
+
else
|
|
368
|
+
obj
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# @see Woods
|
|
4
|
+
module Woods
|
|
5
|
+
class Error < StandardError; end unless defined?(Woods::Error)
|
|
6
|
+
|
|
7
|
+
module Console
|
|
8
|
+
class ValidationError < Woods::Error; end
|
|
9
|
+
|
|
10
|
+
# Validates model names and column names against the Rails schema.
|
|
11
|
+
#
|
|
12
|
+
# In production, validates against AR::Base.descendants and model.column_names.
|
|
13
|
+
# Accepts an injectable registry for testing without Rails.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# validator = ModelValidator.new(registry: { 'User' => %w[id email name] })
|
|
17
|
+
# validator.validate_model!('User') # => true
|
|
18
|
+
# validator.validate_model!('Hacker') # => raises ValidationError
|
|
19
|
+
# validator.validate_column!('User', 'email') # => true
|
|
20
|
+
#
|
|
21
|
+
class ModelValidator
|
|
22
|
+
# @param registry [Hash<String, Array<String>>] Model name => column names mapping
|
|
23
|
+
def initialize(registry:)
|
|
24
|
+
@registry = registry
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Validate that a model name is known.
|
|
28
|
+
#
|
|
29
|
+
# @param model_name [String]
|
|
30
|
+
# @return [true]
|
|
31
|
+
# @raise [ValidationError] if model is unknown
|
|
32
|
+
def validate_model!(model_name)
|
|
33
|
+
return true if @registry.key?(model_name)
|
|
34
|
+
|
|
35
|
+
raise ValidationError, "Unknown model: #{model_name}. Available: #{@registry.keys.sort.join(', ')}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Validate that a column exists on a model.
|
|
39
|
+
#
|
|
40
|
+
# @param model_name [String]
|
|
41
|
+
# @param column_name [String]
|
|
42
|
+
# @return [true]
|
|
43
|
+
# @raise [ValidationError] if column is unknown
|
|
44
|
+
def validate_column!(model_name, column_name)
|
|
45
|
+
validate_model!(model_name)
|
|
46
|
+
columns = @registry[model_name]
|
|
47
|
+
return true if columns.include?(column_name)
|
|
48
|
+
|
|
49
|
+
raise ValidationError,
|
|
50
|
+
"Unknown column '#{column_name}' on #{model_name}. Available: #{columns.sort.join(', ')}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Validate multiple columns at once.
|
|
54
|
+
#
|
|
55
|
+
# @param model_name [String]
|
|
56
|
+
# @param column_names [Array<String>]
|
|
57
|
+
# @return [true]
|
|
58
|
+
# @raise [ValidationError] if any column is unknown
|
|
59
|
+
def validate_columns!(model_name, column_names) # rubocop:disable Naming/PredicateMethod
|
|
60
|
+
column_names.each { |col| validate_column!(model_name, col) }
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# List all known model names.
|
|
65
|
+
#
|
|
66
|
+
# @return [Array<String>]
|
|
67
|
+
def model_names
|
|
68
|
+
@registry.keys.sort
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# List columns for a model.
|
|
72
|
+
#
|
|
73
|
+
# @param model_name [String]
|
|
74
|
+
# @return [Array<String>]
|
|
75
|
+
def columns_for(model_name)
|
|
76
|
+
validate_model!(model_name)
|
|
77
|
+
@registry[model_name].sort
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Console
|
|
7
|
+
# Rack middleware that serves the embedded console MCP server over HTTP.
|
|
8
|
+
#
|
|
9
|
+
# Lazy-builds the MCP server on first request so Rails has fully booted
|
|
10
|
+
# and all models are loaded. Uses ActiveRecord connection pool for thread
|
|
11
|
+
# safety under Puma.
|
|
12
|
+
#
|
|
13
|
+
# @example In config/application.rb or an initializer:
|
|
14
|
+
# config.middleware.use Woods::Console::RackMiddleware, path: '/mcp/console'
|
|
15
|
+
#
|
|
16
|
+
class RackMiddleware
|
|
17
|
+
# @param app [#call] The next Rack app in the middleware stack
|
|
18
|
+
# @param path [String] URL path to mount the MCP endpoint (default: '/mcp/console')
|
|
19
|
+
# @param embedded_read_tools [Boolean] Enable sql/query tools in embedded mode (default: false)
|
|
20
|
+
def initialize(app, path: '/mcp/console', embedded_read_tools: false)
|
|
21
|
+
@app = app
|
|
22
|
+
@path = path
|
|
23
|
+
@embedded_read_tools = embedded_read_tools
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
@transport = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Rack interface — intercepts requests at the configured path.
|
|
29
|
+
#
|
|
30
|
+
# @param env [Hash] Rack environment
|
|
31
|
+
# @return [Array] Rack response triple
|
|
32
|
+
def call(env)
|
|
33
|
+
return @app.call(env) unless env['PATH_INFO'].start_with?(@path)
|
|
34
|
+
|
|
35
|
+
transport = ensure_transport
|
|
36
|
+
request = Rack::Request.new(env)
|
|
37
|
+
transport.handle_request(request)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Thread-safe lazy initialization of the MCP server and transport.
|
|
43
|
+
#
|
|
44
|
+
# @return [MCP::Server::Transports::StreamableHTTPTransport]
|
|
45
|
+
def ensure_transport # rubocop:disable Metrics/MethodLength
|
|
46
|
+
return @transport if @transport
|
|
47
|
+
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
return @transport if @transport
|
|
50
|
+
|
|
51
|
+
require 'woods/console/server'
|
|
52
|
+
|
|
53
|
+
Rails.application.eager_load!
|
|
54
|
+
|
|
55
|
+
registry = ActiveRecord::Base.descendants.each_with_object({}) do |model, hash|
|
|
56
|
+
next if model.abstract_class?
|
|
57
|
+
next unless model.table_exists?
|
|
58
|
+
|
|
59
|
+
hash[model.name] = model.column_names
|
|
60
|
+
rescue StandardError
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
validator = ModelValidator.new(registry: registry)
|
|
65
|
+
|
|
66
|
+
config = Woods.configuration
|
|
67
|
+
redacted = Array(config.console_redacted_columns)
|
|
68
|
+
|
|
69
|
+
# Each HTTP request gets its own connection from the pool.
|
|
70
|
+
# SafeContext wraps that connection in a rolled-back transaction.
|
|
71
|
+
safe_context = SafeContext.new(connection: ActiveRecord::Base.connection)
|
|
72
|
+
|
|
73
|
+
server = Server.build_embedded(
|
|
74
|
+
model_validator: validator,
|
|
75
|
+
safe_context: safe_context,
|
|
76
|
+
redacted_columns: redacted,
|
|
77
|
+
read_tools_enabled: @embedded_read_tools
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
|
|
81
|
+
server.transport = @transport
|
|
82
|
+
@transport
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Stub for environments that don't load ActiveRecord
|
|
4
|
+
unless defined?(ActiveRecord::Rollback)
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
class Rollback < StandardError; end
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module Woods
|
|
11
|
+
module Console
|
|
12
|
+
# Wraps tool execution in a rolled-back transaction with statement timeout.
|
|
13
|
+
#
|
|
14
|
+
# Safety layers:
|
|
15
|
+
# - Every query runs inside a transaction that is always rolled back
|
|
16
|
+
# - Statement timeout prevents runaway queries
|
|
17
|
+
# - Column redaction replaces sensitive values with "[REDACTED]"
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# ctx = SafeContext.new(connection: conn, timeout_ms: 5000, redacted_columns: %w[ssn])
|
|
21
|
+
# ctx.execute { |c| c.execute("SELECT count(*) FROM users") }
|
|
22
|
+
#
|
|
23
|
+
class SafeContext
|
|
24
|
+
# @param connection [Object] Database connection (or mock)
|
|
25
|
+
# @param timeout_ms [Integer] Statement timeout in milliseconds
|
|
26
|
+
# @param redacted_columns [Array<String>] Column names whose values should be redacted
|
|
27
|
+
def initialize(connection:, timeout_ms: 5000, redacted_columns: [])
|
|
28
|
+
@connection = connection
|
|
29
|
+
@timeout_ms = timeout_ms
|
|
30
|
+
@redacted_columns = redacted_columns.map(&:to_s)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Execute a block within a rolled-back transaction with statement timeout.
|
|
34
|
+
#
|
|
35
|
+
# The transaction is always rolled back to ensure read-only behavior.
|
|
36
|
+
#
|
|
37
|
+
# @yield [connection] The database connection
|
|
38
|
+
# @return [Object] The block's return value
|
|
39
|
+
def execute
|
|
40
|
+
result = nil
|
|
41
|
+
@connection.transaction do
|
|
42
|
+
set_timeout
|
|
43
|
+
result = yield(@connection)
|
|
44
|
+
raise ActiveRecord::Rollback
|
|
45
|
+
end
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Replace values of redacted columns with "[REDACTED]".
|
|
50
|
+
#
|
|
51
|
+
# @param hash [Hash] Record attributes
|
|
52
|
+
# @param _model_name [String] Model name (reserved for per-model redaction rules)
|
|
53
|
+
# @return [Hash] Redacted copy of the hash
|
|
54
|
+
def redact(hash, _model_name = nil)
|
|
55
|
+
return hash if @redacted_columns.empty?
|
|
56
|
+
|
|
57
|
+
hash.transform_keys(&:to_s).each_with_object({}) do |(key, value), redacted|
|
|
58
|
+
redacted[key] = @redacted_columns.include?(key) ? '[REDACTED]' : value
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Set statement timeout on the connection.
|
|
65
|
+
#
|
|
66
|
+
# PostgreSQL uses SET statement_timeout (applies to all statement types).
|
|
67
|
+
# MySQL uses SET max_execution_time (applies to SELECT only — MySQL limitation:
|
|
68
|
+
# DDL and DML statements cannot be time-limited via this variable).
|
|
69
|
+
def set_timeout(connection = @connection, timeout_ms = @timeout_ms)
|
|
70
|
+
adapter = connection.adapter_name.downcase
|
|
71
|
+
if adapter.include?('mysql')
|
|
72
|
+
connection.execute("SET max_execution_time = #{timeout_ms.to_i}")
|
|
73
|
+
else
|
|
74
|
+
connection.execute("SET statement_timeout = '#{timeout_ms.to_i}ms'")
|
|
75
|
+
end
|
|
76
|
+
rescue StandardError
|
|
77
|
+
# Unsupported adapter — timeout enforcement is best-effort
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|