woods 1.6.2 → 2.0.0.beta1

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 (278) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1889 -11
  3. data/CONTRIBUTING.md +195 -129
  4. data/README.md +162 -520
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +620 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +415 -0
  19. data/docs/INTERNALS.md +415 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +197 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +77 -10
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +40 -1
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/credential_index.rb +2 -20
  64. data/lib/woods/console/credential_scanner.rb +14 -14
  65. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  66. data/lib/woods/console/embedded_executor.rb +806 -149
  67. data/lib/woods/console/eval_guard.rb +27 -20
  68. data/lib/woods/console/input_contract.rb +78 -0
  69. data/lib/woods/console/model_validator.rb +29 -1
  70. data/lib/woods/console/rack_middleware.rb +63 -43
  71. data/lib/woods/console/redactor.rb +26 -8
  72. data/lib/woods/console/safe_context.rb +58 -10
  73. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  74. data/lib/woods/console/server.rb +135 -265
  75. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  76. data/lib/woods/console/sql_table_scanner.rb +82 -22
  77. data/lib/woods/console/sql_validator.rb +459 -29
  78. data/lib/woods/console/table_gate.rb +2 -2
  79. data/lib/woods/console/tool_specs.rb +462 -88
  80. data/lib/woods/console/tools/tier1.rb +0 -3
  81. data/lib/woods/console/tools/tier4.rb +17 -7
  82. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  83. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  84. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  85. data/lib/woods/db/migrator.rb +3 -9
  86. data/lib/woods/db/schema_version.rb +47 -2
  87. data/lib/woods/dependency_graph.rb +898 -64
  88. data/lib/woods/embedding/fake.rb +138 -0
  89. data/lib/woods/embedding/indexer.rb +832 -40
  90. data/lib/woods/embedding/openai.rb +77 -19
  91. data/lib/woods/embedding/provider.rb +189 -11
  92. data/lib/woods/embedding/text_preparer.rb +1 -1
  93. data/lib/woods/embedding/token_counter.rb +0 -7
  94. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  95. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  96. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  97. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  98. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  99. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  100. data/lib/woods/evaluation/ablation_task.rb +66 -0
  101. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  102. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  103. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  104. data/lib/woods/evaluation/baseline.rb +60 -0
  105. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  106. data/lib/woods/evaluation/evaluator.rb +41 -8
  107. data/lib/woods/evaluation/query_set.rb +79 -13
  108. data/lib/woods/evaluation/report_generator.rb +20 -1
  109. data/lib/woods/export/unit_facts.rb +0 -11
  110. data/lib/woods/extracted_unit.rb +22 -63
  111. data/lib/woods/extractor.rb +2503 -192
  112. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  113. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  114. data/lib/woods/extractors/caching_extractor.rb +46 -12
  115. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  116. data/lib/woods/extractors/component_discovery.rb +123 -0
  117. data/lib/woods/extractors/concern_extractor.rb +17 -3
  118. data/lib/woods/extractors/controller_extractor.rb +389 -29
  119. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  120. data/lib/woods/extractors/engine_extractor.rb +53 -8
  121. data/lib/woods/extractors/event_extractor.rb +55 -4
  122. data/lib/woods/extractors/factory_extractor.rb +49 -11
  123. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  124. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  125. data/lib/woods/extractors/job_extractor.rb +51 -21
  126. data/lib/woods/extractors/lib_extractor.rb +23 -17
  127. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  128. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  129. data/lib/woods/extractors/manager_extractor.rb +19 -2
  130. data/lib/woods/extractors/migration_extractor.rb +22 -11
  131. data/lib/woods/extractors/model_extractor.rb +292 -57
  132. data/lib/woods/extractors/package_extractor.rb +154 -0
  133. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  134. data/lib/woods/extractors/policy_extractor.rb +6 -5
  135. data/lib/woods/extractors/poro_extractor.rb +13 -14
  136. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  137. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  138. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  139. data/lib/woods/extractors/reference_patterns.rb +38 -0
  140. data/lib/woods/extractors/route_extractor.rb +58 -2
  141. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  142. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  143. data/lib/woods/extractors/service_extractor.rb +11 -1
  144. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  145. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  146. data/lib/woods/extractors/source_nesting.rb +560 -0
  147. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  148. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  149. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  150. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  151. data/lib/woods/feedback/gap_detector.rb +9 -3
  152. data/lib/woods/feedback/store.rb +7 -1
  153. data/lib/woods/filename_utils.rb +29 -1
  154. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  155. data/lib/woods/flow_assembler.rb +63 -21
  156. data/lib/woods/flow_document.rb +1 -0
  157. data/lib/woods/flow_precomputer.rb +138 -22
  158. data/lib/woods/gem_mapper.rb +285 -0
  159. data/lib/woods/generation.rb +185 -0
  160. data/lib/woods/git_command.rb +38 -0
  161. data/lib/woods/git_provenance.rb +16 -2
  162. data/lib/woods/graph_analyzer.rb +408 -34
  163. data/lib/woods/index_artifact.rb +93 -23
  164. data/lib/woods/mcp/bearer_auth.rb +102 -13
  165. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  166. data/lib/woods/mcp/bootstrapper.rb +582 -77
  167. data/lib/woods/mcp/config_resolver.rb +66 -6
  168. data/lib/woods/mcp/errors.rb +60 -0
  169. data/lib/woods/mcp/index_reader.rb +836 -117
  170. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  171. data/lib/woods/mcp/origin_guard.rb +66 -7
  172. data/lib/woods/mcp/protocol_policy.rb +98 -0
  173. data/lib/woods/mcp/provider_probe.rb +45 -6
  174. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  175. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  176. data/lib/woods/mcp/server.rb +907 -154
  177. data/lib/woods/mcp/tasks/extension.rb +196 -0
  178. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  179. data/lib/woods/mcp/tasks/store.rb +518 -0
  180. data/lib/woods/mcp/tool_contract.rb +171 -0
  181. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  182. data/lib/woods/mcp/version_aware_tool_dispatch.rb +3 -9
  183. data/lib/woods/model_name_cache.rb +19 -1
  184. data/lib/woods/notion/client.rb +132 -36
  185. data/lib/woods/notion/exporter.rb +456 -61
  186. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  187. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  188. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  189. data/lib/woods/notion/mappers/shared.rb +45 -3
  190. data/lib/woods/notion/sync_manifest.rb +258 -0
  191. data/lib/woods/obsidian/errors.rb +6 -0
  192. data/lib/woods/obsidian/name_mapper.rb +40 -24
  193. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  194. data/lib/woods/operator/pipeline_guard.rb +118 -21
  195. data/lib/woods/operator/status_reporter.rb +20 -3
  196. data/lib/woods/path_dispatcher.rb +276 -0
  197. data/lib/woods/payload_store.rb +223 -0
  198. data/lib/woods/published_index/edge_shaper.rb +61 -0
  199. data/lib/woods/published_index/generation_catalog.rb +72 -0
  200. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  201. data/lib/woods/published_index.rb +287 -0
  202. data/lib/woods/railtie.rb +69 -30
  203. data/lib/woods/railtie_support.rb +167 -0
  204. data/lib/woods/release.rb +12 -0
  205. data/lib/woods/reload_policy.rb +206 -0
  206. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  207. data/lib/woods/resilience/index_validator.rb +296 -10
  208. data/lib/woods/resilience/retryable_provider.rb +71 -6
  209. data/lib/woods/resolved_config.rb +55 -11
  210. data/lib/woods/retrieval/context_assembler.rb +132 -40
  211. data/lib/woods/retrieval/query_classifier.rb +25 -6
  212. data/lib/woods/retrieval/ranker.rb +193 -28
  213. data/lib/woods/retrieval/search_executor.rb +206 -39
  214. data/lib/woods/retriever.rb +317 -71
  215. data/lib/woods/retry_after.rb +22 -2
  216. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  217. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  218. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  219. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  220. data/lib/woods/ruby_analyzer.rb +21 -5
  221. data/lib/woods/session_tracer/file_store.rb +138 -19
  222. data/lib/woods/session_tracer/redis_store.rb +122 -12
  223. data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
  224. data/lib/woods/session_tracer/session_flow_document.rb +52 -6
  225. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  226. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  227. data/lib/woods/session_tracer/store.rb +14 -1
  228. data/lib/woods/storage/metadata_store.rb +230 -26
  229. data/lib/woods/storage/pgvector.rb +180 -22
  230. data/lib/woods/storage/qdrant.rb +367 -41
  231. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  232. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  233. data/lib/woods/storage/snapshotter.rb +23 -5
  234. data/lib/woods/storage/vector_store.rb +49 -8
  235. data/lib/woods/storage_identity.rb +28 -0
  236. data/lib/woods/tasks.rb +53 -2
  237. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  238. data/lib/woods/temporal/snapshot_store.rb +139 -42
  239. data/lib/woods/unblocked/client.rb +119 -17
  240. data/lib/woods/unblocked/document_builder.rb +34 -2
  241. data/lib/woods/unblocked/exporter.rb +63 -27
  242. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  243. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  244. data/lib/woods/update_check.rb +24 -1
  245. data/lib/woods/util/uuid5.rb +124 -0
  246. data/lib/woods/version.rb +1 -1
  247. data/lib/woods/watch/daemon.rb +1345 -0
  248. data/lib/woods/watch/listen_watcher.rb +81 -0
  249. data/lib/woods/watch/polling_watcher.rb +137 -0
  250. data/lib/woods/watch/status.rb +169 -0
  251. data/lib/woods/watch/tree_scan.rb +163 -0
  252. data/lib/woods/watch/watcher.rb +100 -0
  253. data/lib/woods.rb +53 -9
  254. data/plugin/.claude-plugin/plugin.json +18 -0
  255. data/plugin/hooks/hooks.json +29 -0
  256. data/plugin/hooks/woods-post-edit.sh +226 -0
  257. data/plugin/hooks/woods-session-start.sh +77 -0
  258. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  259. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  260. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  261. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  262. data/plugin/skills/woods-setup/SKILL.md +99 -0
  263. metadata +102 -26
  264. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  265. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  266. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  267. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  268. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  269. data/lib/woods/console/bridge.rb +0 -210
  270. data/lib/woods/console/credential_scanner_registry.rb +0 -36
  271. data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
  272. data/lib/woods/formatting/claude_adapter.rb +0 -98
  273. data/lib/woods/formatting/generic_adapter.rb +0 -56
  274. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  275. data/lib/woods/mcp/http_transport_options.rb +0 -24
  276. data/lib/woods/notion/mapper.rb +0 -40
  277. data/lib/woods/observability/health_check.rb +0 -79
  278. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'open3'
5
3
  require 'shellwords'
6
4
 
7
5
  # @see Woods
@@ -11,162 +9,79 @@ module Woods
11
9
  module Console
12
10
  class ConnectionError < Woods::Error; end
13
11
 
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!
12
+ # Resolves and launches an embedded Console MCP process.
28
13
  #
14
+ # The former implementation spoke a private JSON-lines bridge protocol to
15
+ # a scaffold that returned static data. The supported path now forwards the
16
+ # MCP client's stdio directly to the real embedded server by replacing this
17
+ # process with a direct, Docker, or SSH command.
29
18
  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)
19
+ DEFAULT_COMMAND = 'bundle exec rake woods:console'
20
+
21
+ # @param config [Hash] Process-launch configuration
22
+ # @option config [String] 'mode' direct, docker, or ssh (default: direct)
23
+ # @option config [String] 'command' Embedded server command
24
+ # @option config [String] 'directory' Working directory for direct mode
25
+ # @option config [String] 'container' Container name for docker mode
26
+ # @option config [String] 'host' SSH host for ssh mode
27
+ # @option config [String] 'user' Optional SSH user
40
28
  def initialize(config:)
41
29
  @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
30
+ @mode = config.fetch('mode', 'direct')
31
+ @embedded_command = config.fetch('command', DEFAULT_COMMAND)
49
32
  end
50
33
 
51
- # Spawn the bridge process.
34
+ # Return the argv used to launch the embedded MCP server.
52
35
  #
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)
36
+ # @return [Array<String>]
37
+ # @raise [ConnectionError] when the mode is invalid or incomplete
38
+ def command
39
+ case @mode
40
+ when 'direct' then embedded_argv
41
+ when 'docker' then docker_command
42
+ when 'ssh' then ssh_command
43
+ else raise ConnectionError, "Unknown connection mode: #{@mode}"
63
44
  end
64
- @last_heartbeat = Time.now
65
- @retries = 0
66
- rescue StandardError => e
67
- raise ConnectionError, "Failed to connect (#{@mode}): #{e.message}"
68
45
  end
69
46
 
70
- # Terminate the bridge process.
47
+ # Replace the current process with the embedded MCP server.
71
48
  #
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.
49
+ # Process replacement gives the MCP client direct ownership of lifecycle:
50
+ # EOF, INT, and TERM reach the real server without an intermediate process
51
+ # that can hang while waiting for a child.
102
52
  #
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
53
+ # @return [void]
54
+ # @raise [ConnectionError] when the process cannot be launched
55
+ def replace_process!
56
+ if @mode == 'direct' && @config['directory']
57
+ Dir.chdir(@config['directory']) { exec(*command) }
58
+ else
59
+ exec(*command)
60
+ end
61
+ rescue SystemCallError, ArgumentError => e
62
+ raise ConnectionError, "Failed to launch embedded Console MCP (#{@mode}): #{e.message}"
117
63
  end
118
64
 
119
65
  private
120
66
 
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
67
+ def embedded_argv
68
+ argv = @embedded_command.to_s.shellsplit
69
+ raise ConnectionError, 'Console command must not be empty' if argv.empty?
70
+
71
+ argv
72
+ rescue ArgumentError => e
73
+ raise ConnectionError, "Invalid console command: #{e.message}"
131
74
  end
132
75
 
133
- def build_docker_command
76
+ def docker_command
134
77
  container = @config['container'] || raise(ConnectionError, 'Docker mode requires container name')
135
- ['docker', 'exec', '-i', container] + @command.shellsplit
78
+ ['docker', 'exec', '-i', container] + embedded_argv
136
79
  end
137
80
 
138
- def build_ssh_command
81
+ def ssh_command
139
82
  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!
83
+ target = @config['user'] ? "#{@config['user']}@#{host}" : host
84
+ ['ssh', target] + embedded_argv
170
85
  end
171
86
  end
172
87
  end
@@ -68,7 +68,6 @@ module Woods
68
68
  MISSING_KEY_ERRORS = %w[
69
69
  ActiveSupport::EncryptedConfiguration::MissingKeyError
70
70
  ActiveSupport::EncryptedFile::MissingKeyError
71
- ActiveSupport::EncryptedFile::MissingContentError
72
71
  ActiveSupport::MessageEncryptor::InvalidMessage
73
72
  ].freeze
74
73
 
@@ -91,19 +90,13 @@ module Woods
91
90
  # @return [CredentialIndex] populated index, or an empty index when
92
91
  # the credentials store can't be opened.
93
92
  def build(rails_app:)
94
- refresh(rails_app: rails_app)
93
+ new(secrets: collect_secrets(rails_app))
95
94
  rescue StandardError => e
96
95
  raise unless missing_key_error?(e)
97
96
 
98
97
  new(secrets: [])
99
98
  end
100
99
 
101
- # Strict refresh: construct the replacement completely before a caller
102
- # installs it. Unlike boot-time .build, errors must preserve the old index.
103
- def refresh(rails_app:)
104
- new(secrets: collect_secrets(rails_app))
105
- end
106
-
107
100
  # Emit a structured log warning when any of the given credentials
108
101
  # files has a modification time newer than `process_start`. This
109
102
  # catches the common "rotated credentials but forgot to restart"
@@ -143,23 +136,12 @@ module Woods
143
136
  private
144
137
 
145
138
  def collect_secrets(rails_app)
146
- config = fresh_credentials(rails_app.credentials).config
139
+ config = rails_app.credentials.config
147
140
  collected = []
148
141
  walk(config, collected)
149
142
  collected
150
143
  end
151
144
 
152
- def fresh_credentials(credentials)
153
- return credentials unless defined?(ActiveSupport::EncryptedConfiguration) &&
154
- credentials.is_a?(ActiveSupport::EncryptedConfiguration)
155
-
156
- require_relative 'encrypted_credential_snapshot'
157
- EncryptedCredentialSnapshot.new(
158
- config_path: credentials.content_path, key_path: credentials.key_path,
159
- env_key: credentials.env_key, raise_if_missing_key: true
160
- )
161
- end
162
-
163
145
  def walk(node, collected)
164
146
  case node
165
147
  when Hash then node.each_value { |v| walk(v, collected) }
@@ -155,17 +155,17 @@ module Woods
155
155
  # for patterns that fired — callers should treat a missing key as zero.
156
156
  def scan(value)
157
157
  counts = {}
158
- scanned = walk(value, counts, @secret_index)
158
+ scanned = walk(value, counts)
159
159
  [scanned, counts]
160
160
  end
161
161
 
162
162
  private
163
163
 
164
- def walk(value, counts, index)
164
+ def walk(value, counts)
165
165
  case value
166
- when String then scan_string(value, counts, index)
167
- when Hash then walk_hash(value, counts, index)
168
- when Array then value.map { |item| walk(item, counts, index) }
166
+ when String then scan_string(value, counts)
167
+ when Hash then walk_hash(value, counts)
168
+ when Array then value.map { |item| walk(item, counts) }
169
169
  else value
170
170
  end
171
171
  end
@@ -177,19 +177,19 @@ module Woods
177
177
  # credential-shaped string is emitted as a Symbol after redaction
178
178
  # (e.g. `:"[REDACTED]"`). String keys stay Strings. Non-String,
179
179
  # non-Symbol keys (Integer, etc.) pass through unchanged.
180
- def walk_hash(hash, counts, index)
180
+ def walk_hash(hash, counts)
181
181
  hash.each_with_object({}) do |(key, val), out|
182
182
  scanned_key = case key
183
- when String then scan_string(key, counts, index)
184
- when Symbol then scan_string(key.to_s, counts, index).to_sym
183
+ when String then scan_string(key, counts)
184
+ when Symbol then scan_string(key.to_s, counts).to_sym
185
185
  else key
186
186
  end
187
- out[scanned_key] = walk(val, counts, index)
187
+ out[scanned_key] = walk(val, counts)
188
188
  end
189
189
  end
190
190
 
191
- def scan_string(str, counts, index)
192
- result = redact_indexed_secrets(str, counts, index)
191
+ def scan_string(str, counts)
192
+ result = redact_indexed_secrets(str, counts)
193
193
  result = scan_encoded_forms(result, counts)
194
194
  @active_patterns.inject(result) do |acc, (name, pattern)|
195
195
  acc.gsub(pattern) do
@@ -290,10 +290,10 @@ module Woods
290
290
  printable.to_f / str.length >= PRINTABLE_RATIO_THRESHOLD
291
291
  end
292
292
 
293
- def redact_indexed_secrets(str, counts, index)
294
- return str unless index&.match?(str)
293
+ def redact_indexed_secrets(str, counts)
294
+ return str unless @secret_index&.match?(str)
295
295
 
296
- redacted = index.redact(str)
296
+ redacted = @secret_index.redact(str)
297
297
  counts[INDEX_HIT] = (counts[INDEX_HIT] || 0) + redacted.scan(CredentialIndex::REDACTED).size
298
298
  redacted
299
299
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require_relative 'response_context'
5
+ require_relative 'input_contract'
5
6
 
6
7
  module Woods
7
8
  module Console
@@ -10,8 +11,7 @@ module Woods
10
11
  # Encapsulates everything that happens between MCP's `define_tool` block
11
12
  # firing and the `MCP::Tool::Response` returning to the client:
12
13
  #
13
- # 1. Coerce integer-typed args from strings. MCP clients sometimes send
14
- # `"10"` for an `integer` property — we normalize before dispatch.
14
+ # 1. Strictly parse integer strings and enforce schema bounds.
15
15
  # 2. Run the Layer 1 table gate ({ResponseContext#enforce!}).
16
16
  # 3. Translate the tool args into a bridge/executor request via the
17
17
  # handler proc supplied in the {ToolSpec}.
@@ -31,8 +31,7 @@ module Woods
31
31
  class DispatchPipeline
32
32
  # @param tool_name [String]
33
33
  # @param handler [#call] Maps a Hash of tool args to a bridge request Hash.
34
- # @param integer_keys [Array<Symbol>] Property keys declared as `integer`
35
- # in the tool schema.
34
+ # @param properties [Hash] ToolSpec JSON Schema properties.
36
35
  # @param conn_mgr [#send_request] ConnectionManager or EmbeddedExecutor.
37
36
  # @param ctx [ResponseContext] Bundles the three response-safety layers.
38
37
  # Defaults to the Null context so tests can stub minimally.
@@ -40,11 +39,11 @@ module Woods
40
39
  # @param logger [#warn, nil] Structured logger used for credential-scan
41
40
  # and table-gate telemetry. Failures are swallowed so observability
42
41
  # issues never break a tool response.
43
- def initialize(tool_name:, handler:, integer_keys:, conn_mgr:, # rubocop:disable Metrics/ParameterLists
42
+ def initialize(tool_name:, handler:, properties:, conn_mgr:, # rubocop:disable Metrics/ParameterLists
44
43
  ctx: NullResponseContext.instance, renderer: nil, logger: nil)
45
44
  @tool_name = tool_name
46
45
  @handler = handler
47
- @integer_keys = integer_keys
46
+ @properties = properties
48
47
  @conn_mgr = conn_mgr
49
48
  @ctx = ctx
50
49
  @renderer = renderer
@@ -56,21 +55,48 @@ module Woods
56
55
  # @param args [Hash] Tool arguments (symbol keys from MCP).
57
56
  # @return [MCP::Tool::Response]
58
57
  def call(args)
59
- coerce_integer_args!(args)
58
+ InputContract.normalize!(args, @properties)
60
59
  @ctx.enforce!(args)
61
60
  request = @handler.call(args).transform_keys(&:to_s)
62
61
  send_to_bridge(request)
63
62
  rescue TableGateError => e
64
63
  log_table_gate_rejection(args, e)
65
- error_response(e.message)
66
- rescue SqlValidationError, ForbiddenExpressionError => e
67
- error_response(e.message)
64
+ error_response(scan_early_error(e.message))
65
+ rescue InputContract::ValidationError, SqlValidationError, ForbiddenExpressionError => e
66
+ error_response(scan_early_error(e.message))
67
+ rescue StandardError => e
68
+ # Catch-all for pipeline bugs — a renderer NoMethodError, an encoding
69
+ # oddity, a handler defect (L9). Without it the exception escaped into
70
+ # the `mcp` gem's own handling of a raising tool block, which can echo
71
+ # `Class: message` to the client: a leak channel the executor's
72
+ # responses are already sanitized against. Answer with the same
73
+ # sanitized shape the executor uses (class name only, details logged
74
+ # server-side), still routed through the credential scan so nothing an
75
+ # exception message picked up reaches the client either.
76
+ log_unexpected_error(e)
77
+ error_response(scan_early_error(sanitized_unexpected_error(e)))
68
78
  end
69
79
 
70
80
  private
71
81
 
72
- def coerce_integer_args!(args)
73
- @integer_keys.each { |k| args[k] = args[k].to_i if args[k].is_a?(String) }
82
+ # Client-safe stand-in for an unexpected exception. Names the tool so an
83
+ # agent can retry or report, and nothing else — the class and message
84
+ # go to the log instead.
85
+ def sanitized_unexpected_error(_error)
86
+ "Internal error in #{@tool_name} (details logged server-side)"
87
+ end
88
+
89
+ def log_unexpected_error(error)
90
+ return unless @logger
91
+
92
+ @logger.warn(
93
+ 'console.dispatch.unexpected_error',
94
+ tool: @tool_name,
95
+ error_class: error.class.name,
96
+ message: error.message
97
+ )
98
+ rescue StandardError
99
+ nil
74
100
  end
75
101
 
76
102
  def send_to_bridge(request)
@@ -98,6 +124,10 @@ module Woods
98
124
  scanned
99
125
  end
100
126
 
127
+ def scan_early_error(message)
128
+ scan_for_credentials(message, 'tool' => @tool_name.delete_prefix('console_'))
129
+ end
130
+
101
131
  def log_credential_hits(request, counts)
102
132
  return unless @logger
103
133