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.
Files changed (185) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +89 -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 +406 -0
  7. data/exe/woods-console +59 -0
  8. data/exe/woods-console-mcp +22 -0
  9. data/exe/woods-mcp +34 -0
  10. data/exe/woods-mcp-http +37 -0
  11. data/exe/woods-mcp-start +58 -0
  12. data/lib/generators/woods/install_generator.rb +32 -0
  13. data/lib/generators/woods/pgvector_generator.rb +37 -0
  14. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +15 -0
  15. data/lib/generators/woods/templates/create_woods_tables.rb.erb +43 -0
  16. data/lib/tasks/woods.rake +621 -0
  17. data/lib/tasks/woods_evaluation.rake +115 -0
  18. data/lib/woods/ast/call_site_extractor.rb +106 -0
  19. data/lib/woods/ast/method_extractor.rb +71 -0
  20. data/lib/woods/ast/node.rb +116 -0
  21. data/lib/woods/ast/parser.rb +614 -0
  22. data/lib/woods/ast.rb +6 -0
  23. data/lib/woods/builder.rb +200 -0
  24. data/lib/woods/cache/cache_middleware.rb +199 -0
  25. data/lib/woods/cache/cache_store.rb +264 -0
  26. data/lib/woods/cache/redis_cache_store.rb +116 -0
  27. data/lib/woods/cache/solid_cache_store.rb +111 -0
  28. data/lib/woods/chunking/chunk.rb +84 -0
  29. data/lib/woods/chunking/semantic_chunker.rb +295 -0
  30. data/lib/woods/console/adapters/cache_adapter.rb +58 -0
  31. data/lib/woods/console/adapters/good_job_adapter.rb +33 -0
  32. data/lib/woods/console/adapters/job_adapter.rb +68 -0
  33. data/lib/woods/console/adapters/sidekiq_adapter.rb +33 -0
  34. data/lib/woods/console/adapters/solid_queue_adapter.rb +33 -0
  35. data/lib/woods/console/audit_logger.rb +75 -0
  36. data/lib/woods/console/bridge.rb +177 -0
  37. data/lib/woods/console/confirmation.rb +90 -0
  38. data/lib/woods/console/connection_manager.rb +173 -0
  39. data/lib/woods/console/console_response_renderer.rb +74 -0
  40. data/lib/woods/console/embedded_executor.rb +373 -0
  41. data/lib/woods/console/model_validator.rb +81 -0
  42. data/lib/woods/console/rack_middleware.rb +87 -0
  43. data/lib/woods/console/safe_context.rb +82 -0
  44. data/lib/woods/console/server.rb +612 -0
  45. data/lib/woods/console/sql_validator.rb +172 -0
  46. data/lib/woods/console/tools/tier1.rb +118 -0
  47. data/lib/woods/console/tools/tier2.rb +117 -0
  48. data/lib/woods/console/tools/tier3.rb +110 -0
  49. data/lib/woods/console/tools/tier4.rb +79 -0
  50. data/lib/woods/coordination/pipeline_lock.rb +109 -0
  51. data/lib/woods/cost_model/embedding_cost.rb +88 -0
  52. data/lib/woods/cost_model/estimator.rb +128 -0
  53. data/lib/woods/cost_model/provider_pricing.rb +67 -0
  54. data/lib/woods/cost_model/storage_cost.rb +52 -0
  55. data/lib/woods/cost_model.rb +22 -0
  56. data/lib/woods/db/migrations/001_create_units.rb +38 -0
  57. data/lib/woods/db/migrations/002_create_edges.rb +35 -0
  58. data/lib/woods/db/migrations/003_create_embeddings.rb +37 -0
  59. data/lib/woods/db/migrations/004_create_snapshots.rb +45 -0
  60. data/lib/woods/db/migrations/005_create_snapshot_units.rb +40 -0
  61. data/lib/woods/db/migrations/006_rename_tables.rb +34 -0
  62. data/lib/woods/db/migrator.rb +73 -0
  63. data/lib/woods/db/schema_version.rb +73 -0
  64. data/lib/woods/dependency_graph.rb +236 -0
  65. data/lib/woods/embedding/indexer.rb +140 -0
  66. data/lib/woods/embedding/openai.rb +126 -0
  67. data/lib/woods/embedding/provider.rb +162 -0
  68. data/lib/woods/embedding/text_preparer.rb +112 -0
  69. data/lib/woods/evaluation/baseline_runner.rb +115 -0
  70. data/lib/woods/evaluation/evaluator.rb +139 -0
  71. data/lib/woods/evaluation/metrics.rb +79 -0
  72. data/lib/woods/evaluation/query_set.rb +148 -0
  73. data/lib/woods/evaluation/report_generator.rb +90 -0
  74. data/lib/woods/extracted_unit.rb +145 -0
  75. data/lib/woods/extractor.rb +1028 -0
  76. data/lib/woods/extractors/action_cable_extractor.rb +201 -0
  77. data/lib/woods/extractors/ast_source_extraction.rb +46 -0
  78. data/lib/woods/extractors/behavioral_profile.rb +309 -0
  79. data/lib/woods/extractors/caching_extractor.rb +261 -0
  80. data/lib/woods/extractors/callback_analyzer.rb +246 -0
  81. data/lib/woods/extractors/concern_extractor.rb +292 -0
  82. data/lib/woods/extractors/configuration_extractor.rb +219 -0
  83. data/lib/woods/extractors/controller_extractor.rb +404 -0
  84. data/lib/woods/extractors/database_view_extractor.rb +278 -0
  85. data/lib/woods/extractors/decorator_extractor.rb +253 -0
  86. data/lib/woods/extractors/engine_extractor.rb +223 -0
  87. data/lib/woods/extractors/event_extractor.rb +211 -0
  88. data/lib/woods/extractors/factory_extractor.rb +289 -0
  89. data/lib/woods/extractors/graphql_extractor.rb +892 -0
  90. data/lib/woods/extractors/i18n_extractor.rb +117 -0
  91. data/lib/woods/extractors/job_extractor.rb +374 -0
  92. data/lib/woods/extractors/lib_extractor.rb +218 -0
  93. data/lib/woods/extractors/mailer_extractor.rb +269 -0
  94. data/lib/woods/extractors/manager_extractor.rb +188 -0
  95. data/lib/woods/extractors/middleware_extractor.rb +133 -0
  96. data/lib/woods/extractors/migration_extractor.rb +469 -0
  97. data/lib/woods/extractors/model_extractor.rb +988 -0
  98. data/lib/woods/extractors/phlex_extractor.rb +252 -0
  99. data/lib/woods/extractors/policy_extractor.rb +191 -0
  100. data/lib/woods/extractors/poro_extractor.rb +229 -0
  101. data/lib/woods/extractors/pundit_extractor.rb +223 -0
  102. data/lib/woods/extractors/rails_source_extractor.rb +473 -0
  103. data/lib/woods/extractors/rake_task_extractor.rb +343 -0
  104. data/lib/woods/extractors/route_extractor.rb +181 -0
  105. data/lib/woods/extractors/scheduled_job_extractor.rb +331 -0
  106. data/lib/woods/extractors/serializer_extractor.rb +339 -0
  107. data/lib/woods/extractors/service_extractor.rb +217 -0
  108. data/lib/woods/extractors/shared_dependency_scanner.rb +91 -0
  109. data/lib/woods/extractors/shared_utility_methods.rb +281 -0
  110. data/lib/woods/extractors/state_machine_extractor.rb +398 -0
  111. data/lib/woods/extractors/test_mapping_extractor.rb +225 -0
  112. data/lib/woods/extractors/validator_extractor.rb +211 -0
  113. data/lib/woods/extractors/view_component_extractor.rb +311 -0
  114. data/lib/woods/extractors/view_template_extractor.rb +261 -0
  115. data/lib/woods/feedback/gap_detector.rb +89 -0
  116. data/lib/woods/feedback/store.rb +119 -0
  117. data/lib/woods/filename_utils.rb +32 -0
  118. data/lib/woods/flow_analysis/operation_extractor.rb +206 -0
  119. data/lib/woods/flow_analysis/response_code_mapper.rb +154 -0
  120. data/lib/woods/flow_assembler.rb +290 -0
  121. data/lib/woods/flow_document.rb +191 -0
  122. data/lib/woods/flow_precomputer.rb +102 -0
  123. data/lib/woods/formatting/base.rb +30 -0
  124. data/lib/woods/formatting/claude_adapter.rb +98 -0
  125. data/lib/woods/formatting/generic_adapter.rb +56 -0
  126. data/lib/woods/formatting/gpt_adapter.rb +64 -0
  127. data/lib/woods/formatting/human_adapter.rb +78 -0
  128. data/lib/woods/graph_analyzer.rb +374 -0
  129. data/lib/woods/mcp/bootstrapper.rb +96 -0
  130. data/lib/woods/mcp/index_reader.rb +394 -0
  131. data/lib/woods/mcp/renderers/claude_renderer.rb +81 -0
  132. data/lib/woods/mcp/renderers/json_renderer.rb +17 -0
  133. data/lib/woods/mcp/renderers/markdown_renderer.rb +353 -0
  134. data/lib/woods/mcp/renderers/plain_renderer.rb +240 -0
  135. data/lib/woods/mcp/server.rb +962 -0
  136. data/lib/woods/mcp/tool_response_renderer.rb +85 -0
  137. data/lib/woods/model_name_cache.rb +51 -0
  138. data/lib/woods/notion/client.rb +217 -0
  139. data/lib/woods/notion/exporter.rb +219 -0
  140. data/lib/woods/notion/mapper.rb +40 -0
  141. data/lib/woods/notion/mappers/column_mapper.rb +57 -0
  142. data/lib/woods/notion/mappers/migration_mapper.rb +39 -0
  143. data/lib/woods/notion/mappers/model_mapper.rb +161 -0
  144. data/lib/woods/notion/mappers/shared.rb +22 -0
  145. data/lib/woods/notion/rate_limiter.rb +68 -0
  146. data/lib/woods/observability/health_check.rb +79 -0
  147. data/lib/woods/observability/instrumentation.rb +34 -0
  148. data/lib/woods/observability/structured_logger.rb +57 -0
  149. data/lib/woods/operator/error_escalator.rb +81 -0
  150. data/lib/woods/operator/pipeline_guard.rb +92 -0
  151. data/lib/woods/operator/status_reporter.rb +80 -0
  152. data/lib/woods/railtie.rb +38 -0
  153. data/lib/woods/resilience/circuit_breaker.rb +99 -0
  154. data/lib/woods/resilience/index_validator.rb +167 -0
  155. data/lib/woods/resilience/retryable_provider.rb +108 -0
  156. data/lib/woods/retrieval/context_assembler.rb +261 -0
  157. data/lib/woods/retrieval/query_classifier.rb +133 -0
  158. data/lib/woods/retrieval/ranker.rb +277 -0
  159. data/lib/woods/retrieval/search_executor.rb +316 -0
  160. data/lib/woods/retriever.rb +152 -0
  161. data/lib/woods/ruby_analyzer/class_analyzer.rb +170 -0
  162. data/lib/woods/ruby_analyzer/dataflow_analyzer.rb +77 -0
  163. data/lib/woods/ruby_analyzer/fqn_builder.rb +18 -0
  164. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +280 -0
  165. data/lib/woods/ruby_analyzer/method_analyzer.rb +143 -0
  166. data/lib/woods/ruby_analyzer/trace_enricher.rb +143 -0
  167. data/lib/woods/ruby_analyzer.rb +87 -0
  168. data/lib/woods/session_tracer/file_store.rb +104 -0
  169. data/lib/woods/session_tracer/middleware.rb +143 -0
  170. data/lib/woods/session_tracer/redis_store.rb +106 -0
  171. data/lib/woods/session_tracer/session_flow_assembler.rb +254 -0
  172. data/lib/woods/session_tracer/session_flow_document.rb +223 -0
  173. data/lib/woods/session_tracer/solid_cache_store.rb +139 -0
  174. data/lib/woods/session_tracer/store.rb +81 -0
  175. data/lib/woods/storage/graph_store.rb +120 -0
  176. data/lib/woods/storage/metadata_store.rb +196 -0
  177. data/lib/woods/storage/pgvector.rb +195 -0
  178. data/lib/woods/storage/qdrant.rb +205 -0
  179. data/lib/woods/storage/vector_store.rb +167 -0
  180. data/lib/woods/temporal/json_snapshot_store.rb +245 -0
  181. data/lib/woods/temporal/snapshot_store.rb +345 -0
  182. data/lib/woods/token_utils.rb +19 -0
  183. data/lib/woods/version.rb +5 -0
  184. data/lib/woods.rb +246 -0
  185. metadata +270 -0
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Console
5
+ module Adapters
6
+ # Base class for job backend adapters.
7
+ #
8
+ # Subclasses implement `self.available?` and a private `prefix` method.
9
+ # The prefix is used to build bridge tool names (e.g., "sidekiq_queue_stats").
10
+ #
11
+ # @example
12
+ # class MyAdapter < JobAdapter
13
+ # def self.available? = !!defined?(::MyQueue)
14
+ # private
15
+ # def prefix = 'my_queue'
16
+ # end
17
+ #
18
+ class JobAdapter
19
+ # Get queue statistics (sizes, latencies).
20
+ #
21
+ # @return [Hash] Bridge request
22
+ def queue_stats
23
+ { tool: "#{prefix}_queue_stats", params: {} }
24
+ end
25
+
26
+ # List recent job failures.
27
+ #
28
+ # @param limit [Integer] Max failures (default: 10, max: 100)
29
+ # @return [Hash] Bridge request
30
+ def recent_failures(limit: 10)
31
+ limit = [limit, 100].min
32
+ { tool: "#{prefix}_recent_failures", params: { limit: limit } }
33
+ end
34
+
35
+ # Find a job by its ID.
36
+ #
37
+ # @param id [Object] Job ID
38
+ # @return [Hash] Bridge request
39
+ def find_job(id:)
40
+ { tool: "#{prefix}_find_job", params: { id: id } }
41
+ end
42
+
43
+ # List scheduled jobs.
44
+ #
45
+ # @param limit [Integer] Max jobs (default: 20, max: 100)
46
+ # @return [Hash] Bridge request
47
+ def scheduled_jobs(limit: 20)
48
+ limit = [limit, 100].min
49
+ { tool: "#{prefix}_scheduled_jobs", params: { limit: limit } }
50
+ end
51
+
52
+ # Retry a failed job.
53
+ #
54
+ # @param id [Object] Job ID
55
+ # @return [Hash] Bridge request
56
+ def retry_job(id:)
57
+ { tool: "#{prefix}_retry_job", params: { id: id } }
58
+ end
59
+
60
+ private
61
+
62
+ def prefix
63
+ raise NotImplementedError, "#{self.class}#prefix must be implemented"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'job_adapter'
4
+
5
+ module Woods
6
+ module Console
7
+ module Adapters
8
+ # Job backend adapter for Sidekiq.
9
+ #
10
+ # Builds bridge requests for Sidekiq queue stats, failure listing,
11
+ # job lookup, scheduled jobs, and retry operations.
12
+ #
13
+ # @example
14
+ # adapter = SidekiqAdapter.new
15
+ # adapter.queue_stats # => { tool: 'sidekiq_queue_stats', params: {} }
16
+ #
17
+ class SidekiqAdapter < JobAdapter
18
+ # Check if Sidekiq is available in the current environment.
19
+ #
20
+ # @return [Boolean]
21
+ def self.available?
22
+ !!defined?(::Sidekiq)
23
+ end
24
+
25
+ private
26
+
27
+ def prefix
28
+ 'sidekiq'
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'job_adapter'
4
+
5
+ module Woods
6
+ module Console
7
+ module Adapters
8
+ # Job backend adapter for Solid Queue.
9
+ #
10
+ # Builds bridge requests for Solid Queue job stats, failure listing,
11
+ # job lookup, scheduled jobs, and retry operations.
12
+ #
13
+ # @example
14
+ # adapter = SolidQueueAdapter.new
15
+ # adapter.queue_stats # => { tool: 'solid_queue_queue_stats', params: {} }
16
+ #
17
+ class SolidQueueAdapter < JobAdapter
18
+ # Check if Solid Queue is available in the current environment.
19
+ #
20
+ # @return [Boolean]
21
+ def self.available?
22
+ !!defined?(::SolidQueue)
23
+ end
24
+
25
+ private
26
+
27
+ def prefix
28
+ 'solid_queue'
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module Woods
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,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'model_validator'
5
+ require_relative 'safe_context'
6
+
7
+ module Woods
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
+ # Alias used by EmbeddedExecutor to avoid duplicating the list.
27
+ TIER1_TOOLS = SUPPORTED_TOOLS
28
+ TOOL_HANDLERS = SUPPORTED_TOOLS.to_h { |t| [t, :"handle_#{t}"] }.freeze
29
+
30
+ # @param input [IO] Input stream (reads JSON-lines)
31
+ # @param output [IO] Output stream (writes JSON-lines)
32
+ # @param model_validator [ModelValidator] Validates model/column names
33
+ # @param safe_context [SafeContext] Wraps execution in safe transaction
34
+ def initialize(input:, output:, model_validator:, safe_context:)
35
+ @input = input
36
+ @output = output
37
+ @model_validator = model_validator
38
+ @safe_context = safe_context
39
+ end
40
+
41
+ # Read loop — processes requests until input is closed.
42
+ #
43
+ # @return [void]
44
+ def run
45
+ @input.each_line do |line|
46
+ line = line.strip
47
+ next if line.empty?
48
+
49
+ request = parse_request(line)
50
+ next unless request
51
+
52
+ response = handle_request(request)
53
+ write_response(response)
54
+ end
55
+ end
56
+
57
+ # Process a single request hash and return a response hash.
58
+ #
59
+ # @param request [Hash] Parsed request with "id", "tool", "params"
60
+ # @return [Hash] Response with "id", "ok", and "result" or "error"
61
+ def handle_request(request)
62
+ id = request['id']
63
+ tool = request['tool']
64
+ params = request['params'] || {}
65
+
66
+ return error_response(id, "Unknown tool: #{tool}", 'unknown_tool') unless SUPPORTED_TOOLS.include?(tool)
67
+
68
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
+ result = dispatch(tool, params)
70
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
71
+
72
+ { 'id' => id, 'ok' => true, 'result' => result, 'timing_ms' => elapsed }
73
+ rescue ValidationError => e
74
+ error_response(id, e.message, 'validation')
75
+ rescue StandardError => e
76
+ error_response(id, e.message, 'execution')
77
+ end
78
+
79
+ private
80
+
81
+ # Parse a JSON line into a request hash.
82
+ #
83
+ # @param line [String] Raw JSON line
84
+ # @return [Hash, nil] Parsed request or nil on parse error
85
+ def parse_request(line)
86
+ JSON.parse(line)
87
+ rescue JSON::ParserError => e
88
+ write_response(error_response(nil, "Invalid JSON: #{e.message}", 'parse'))
89
+ nil
90
+ end
91
+
92
+ # Dispatch a tool request to the appropriate handler.
93
+ #
94
+ # @param tool [String] Tool name
95
+ # @param params [Hash] Tool parameters
96
+ # @return [Hash] Tool result
97
+ def dispatch(tool, params)
98
+ case tool
99
+ when 'status'
100
+ handle_status
101
+ when 'schema'
102
+ handle_schema(params)
103
+ else
104
+ validate_model_param(params)
105
+ handler = TOOL_HANDLERS.fetch(tool) { raise ValidationError, "Unknown tool: #{tool}" }
106
+ send(handler, params)
107
+ end
108
+ end
109
+
110
+ # Validate that the model parameter is present and known.
111
+ def validate_model_param(params)
112
+ model = params['model']
113
+ raise ValidationError, 'Missing required parameter: model' unless model
114
+
115
+ @model_validator.validate_model!(model)
116
+ end
117
+
118
+ # Stub handlers below return empty/zero data by design.
119
+ # This Bridge class is a protocol scaffold — real execution happens
120
+ # in EmbeddedExecutor (in-process) or a live Rails bridge process.
121
+ # The stubs satisfy the protocol contract for testing and offline use.
122
+
123
+ def handle_count(_params)
124
+ { 'count' => 0 }
125
+ end
126
+
127
+ def handle_sample(_params)
128
+ { 'records' => [] }
129
+ end
130
+
131
+ def handle_find(_params)
132
+ { 'record' => nil }
133
+ end
134
+
135
+ def handle_pluck(params)
136
+ @model_validator.validate_columns!(params['model'], params['columns']) if params['columns']
137
+ { 'values' => [] }
138
+ end
139
+
140
+ def handle_aggregate(params)
141
+ @model_validator.validate_column!(params['model'], params['column']) if params['column']
142
+ { 'value' => nil }
143
+ end
144
+
145
+ def handle_association_count(_params)
146
+ { 'count' => 0 }
147
+ end
148
+
149
+ def handle_schema(params)
150
+ model = params['model']
151
+ raise ValidationError, 'Missing required parameter: model' unless model
152
+
153
+ @model_validator.validate_model!(model)
154
+ { 'columns' => @model_validator.columns_for(model), 'indexes' => [] }
155
+ end
156
+
157
+ def handle_recent(_params)
158
+ { 'records' => [] }
159
+ end
160
+
161
+ def handle_status
162
+ { 'status' => 'ok', 'models' => @model_validator.model_names }
163
+ end
164
+
165
+ # Build an error response hash.
166
+ def error_response(id, message, error_type)
167
+ { 'id' => id, 'ok' => false, 'error' => message, 'error_type' => error_type }
168
+ end
169
+
170
+ # Write a JSON-line response to the output stream.
171
+ def write_response(response)
172
+ @output.puts(JSON.generate(response))
173
+ @output.flush
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,90 @@
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 ConfirmationDeniedError < Woods::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 Woods
8
+ module Woods
9
+ class Error < StandardError; end unless defined?(Woods::Error)
10
+
11
+ module Console
12
+ class ConnectionError < Woods::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/woods/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