woods 1.6.4 → 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 (282) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1879 -37
  3. data/CONTRIBUTING.md +195 -137
  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 +39 -3
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +78 -24
  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 +18 -13
  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 +5 -53
  64. data/lib/woods/console/credential_scanner.rb +15 -16
  65. data/lib/woods/console/dispatch_pipeline.rb +46 -34
  66. data/lib/woods/console/embedded_executor.rb +806 -257
  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 +24 -6
  70. data/lib/woods/console/rack_middleware.rb +62 -63
  71. data/lib/woods/console/redactor.rb +10 -24
  72. data/lib/woods/console/safe_context.rb +45 -45
  73. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  74. data/lib/woods/console/server.rb +136 -267
  75. data/lib/woods/console/sql_noise_stripper.rb +20 -51
  76. data/lib/woods/console/sql_table_scanner.rb +39 -90
  77. data/lib/woods/console/sql_validator.rb +455 -85
  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 +92 -22
  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 +108 -23
  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 +70 -38
  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 -30
  264. data/lib/woods/console/adapter_family.rb +0 -39
  265. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  266. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  268. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  269. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  270. data/lib/woods/console/bridge.rb +0 -210
  271. data/lib/woods/console/credential_scanner_registry.rb +0 -36
  272. data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
  273. data/lib/woods/console/sql_output_policy.rb +0 -535
  274. data/lib/woods/console/sqlite_read_guard.rb +0 -46
  275. data/lib/woods/formatting/claude_adapter.rb +0 -98
  276. data/lib/woods/formatting/generic_adapter.rb +0 -56
  277. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  278. data/lib/woods/mcp/http_transport_options.rb +0 -15
  279. data/lib/woods/mcp/origin_policy.rb +0 -113
  280. data/lib/woods/notion/mapper.rb +0 -40
  281. data/lib/woods/observability/health_check.rb +0 -79
  282. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -14,13 +14,15 @@ module Woods
14
14
  #
15
15
  # ## Reachability (v0.2)
16
16
  #
17
- # EvalGuard is the first of five controls on the embedded `console_eval`
18
- # opt-in path. `EmbeddedExecutor#handle_eval` calls `check!` before
19
- # anything else — ahead of the Confirmation prompt, the SafeContext
20
- # rollback, the timeout, and the audit log. When the opt-in is off
21
- # (the default), `refusal_for('eval')` still short-circuits with the
22
- # `eval_disabled` payload and this guard is not reached. See
23
- # docs/CONSOLE_MCP_SETUP.md "console_eval opt-in" and backlog B-053.
17
+ # `EmbeddedExecutor#send_request` already wraps every dispatched tool —
18
+ # `console_eval` included — inside `SafeContext#execute`'s rolled-back
19
+ # transaction before `handle_eval` runs at all. Within that,
20
+ # `handle_eval` calls `EvalGuard#check!` before anything else it
21
+ # controls: ahead of the Confirmation prompt, the timeout, and the audit
22
+ # log. When the opt-in is off (the default), `refusal_for('eval')` still
23
+ # short-circuits with the `eval_disabled` payload and this guard is not
24
+ # reached. See docs/CONSOLE_MCP_SETUP.md "console_eval opt-in" and
25
+ # backlog B-053.
24
26
  #
25
27
  # Bridge-process mode (in development) will call the same guard before
26
28
  # shipping the payload to the remote Rails worker.
@@ -98,24 +100,29 @@ module Woods
98
100
  # `remove_method` / `method_defined?` / `prepend` / `include_module`
99
101
  # reflection family.
100
102
  # - State mutation: `instance_variable_set` / `instance_variable_get`,
101
- # `class_variable_set` / `class_variable_get` / `freeze` / `taint`.
103
+ # `class_variable_set` / `class_variable_get`.
102
104
  # - Object-space escapes: `_id2ref`, `each_object`, `const_source_location`.
103
105
  # - System / process: `system`, `exec`, `spawn`, `fork`, `popen`, `%x{}`
104
106
  # (AST method name `backtick` / xstr) so they can't be invoked
105
107
  # implicitly.
106
- # - File / IO: `open` (bare Kernel#open — the File-specific reader is
107
- # handled separately via CREDENTIAL_FILE_READERS, but the bare
108
- # `Kernel.open("|shell-command")` form is how most shellshock-style
109
- # escapes slip through).
110
- # - Network: `URI.open` (when called as `open` on URI, the AST method
111
- # name is `open` so the string match above catches it). HTTP / Socket
112
- # constants are denied separately via DENIED_CONSTANTS.
113
- # - Loader: `load`, `require`, `require_relative`, `autoload`.
108
+ # - Loader: `require`, `require_relative`, `autoload`. Bare `load` is
109
+ # NOT in this list (Marshal.load/YAML.load call sites are the ones
110
+ # worth denying, and those go through the constant gate below).
114
111
  # - Unsafe deserialization: `unsafe_load` / `_load` (Marshal.load and
115
- # YAML.load are denied via DENIED_CONSTANTS + method gate below).
116
- # - Threading escapes from SafeContext's rollback: `new` on Thread /
117
- # Fiber / Process is denied via DENIED_CONSTANTS so the
118
- # {Kernel.fork, Thread.new} pair can't slip past.
112
+ # YAML.load are denied via DENIED_CONSTANTS on the `Marshal`/`YAML`
113
+ # receiver, not through this list).
114
+ # - Legacy taint-mode escapes: `taint` / `untaint`.
115
+ #
116
+ # Bare `open` (Kernel#open, e.g. `open("|shell-command")`) is NOT in
117
+ # this list and is NOT denied — a receiver-less call has no receiver
118
+ # for {#refuse_credential_file_read!} to match either. `File.open` /
119
+ # `IO.open` / `Pathname.new(...).open` are caught only when the
120
+ # argument looks like a credential path, via CREDENTIAL_FILE_READERS
121
+ # below. `URI.open` is denied, but via `URI` being a denied receiver
122
+ # in DENIED_CONSTANTS ({#refuse_denied_constant_receiver!}), not via an
123
+ # `open` entry here. Threading escapes from SafeContext's rollback
124
+ # (`Thread.new`, `Fiber.new`, `Process.fork`, ...) are denied the same
125
+ # way, via their receiver constants.
119
126
  DENIED_REFLECTION = %w[
120
127
  eval instance_eval class_eval module_eval binding
121
128
  instance_exec class_exec module_exec
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Console
5
+ # Normalizes integer inputs and enforces the bounds declared by a ToolSpec.
6
+ module InputContract
7
+ class ValidationError < StandardError; end
8
+
9
+ DECIMAL_INTEGER = /\A-?(?:0|[1-9]\d*)\z/
10
+ private_constant :DECIMAL_INTEGER
11
+
12
+ module_function
13
+
14
+ def normalize!(arguments, properties)
15
+ properties.each do |property_name, definition|
16
+ next unless definition[:type] == 'integer'
17
+
18
+ key = argument_key(arguments, property_name)
19
+ next unless key
20
+
21
+ value = parse_integer(arguments[key], property_name)
22
+ enforce_bounds!(value, property_name, definition)
23
+ arguments[key] = value
24
+ end
25
+
26
+ arguments
27
+ end
28
+
29
+ # Reject a String value for any integer-typed property, without
30
+ # attempting to parse it. Used ahead of {normalize!} on paths that skip
31
+ # full JSON-Schema validation (e.g. an unregistered tool spec): without
32
+ # this, a well-formed decimal string like `"15"` sails past
33
+ # {parse_integer} untouched (it's valid input to that method) even
34
+ # though the declared schema's `type: integer` would reject any String
35
+ # outright — silently coercing input the public contract disallows.
36
+ #
37
+ # @param arguments [Hash] Mutated arguments hash (string or symbol keys)
38
+ # @param properties [Hash] ToolSpec JSON Schema property definitions
39
+ # @raise [ValidationError] when an integer-typed property holds a String
40
+ def reject_string_typed_integers!(arguments, properties)
41
+ properties.each do |property_name, definition|
42
+ next unless definition[:type] == 'integer'
43
+
44
+ key = argument_key(arguments, property_name)
45
+ next unless key
46
+ next unless arguments[key].is_a?(String)
47
+
48
+ raise ValidationError, "#{property_name} must be an integer"
49
+ end
50
+ end
51
+
52
+ def argument_key(arguments, property_name)
53
+ return property_name if arguments.key?(property_name)
54
+
55
+ string_name = property_name.to_s
56
+ string_name if arguments.key?(string_name)
57
+ end
58
+ private_class_method :argument_key
59
+
60
+ def parse_integer(value, property_name)
61
+ return value if value.is_a?(Integer)
62
+ return Integer(value, 10) if value.is_a?(String) && DECIMAL_INTEGER.match?(value)
63
+
64
+ raise ValidationError, "#{property_name} must be an integer"
65
+ end
66
+ private_class_method :parse_integer
67
+
68
+ def enforce_bounds!(value, property_name, definition)
69
+ minimum = definition.fetch(:minimum)
70
+ maximum = definition.fetch(:maximum)
71
+ return if value.between?(minimum, maximum)
72
+
73
+ raise ValidationError, "#{property_name} must be between #{minimum} and #{maximum}"
74
+ end
75
+ private_class_method :enforce_bounds!
76
+ end
77
+ end
78
+ end
@@ -20,7 +20,11 @@ module Woods
20
20
  #
21
21
  class ModelValidator
22
22
  # @param registry [Hash<String, Array<String>>] Model name => column names mapping
23
- # @param table_names [Hash<String, String>] Optional table mapping for EAV provenance
23
+ # @param table_names [Hash<String, String>] Model name => table name mapping, used to
24
+ # resolve a qualified `table.column` reference back to the model that owns it. Optional
25
+ # and defaults to empty — callers that don't supply it get every qualified column
26
+ # reference rejected by {#validate_table_column!} (fail closed: an unmapped table can't
27
+ # be proven safe).
24
28
  def initialize(registry:, table_names: {})
25
29
  @registry = registry
26
30
  @model_by_table = table_names.each_with_object({}) { |(model, table), acc| acc[table.to_s] = model }
@@ -61,12 +65,26 @@ module Woods
61
65
  column_names.each { |col| validate_column!(model_name, col) }
62
66
  end
63
67
 
64
- # Return known table columns without guessing an unknown table's shape.
68
+ # Validate a qualified `table.column` reference against the real
69
+ # columns of the model that owns `table_name`. Table gating (TableGate)
70
+ # only checks whether a table is *blocked*; it never confirms the
71
+ # column exists on that table at all, so a syntactically valid but
72
+ # nonexistent qualified column previously reached SQL execution and
73
+ # failed as a generic adapter error instead of a typed one.
74
+ #
65
75
  # @param table_name [String]
66
- # @return [Array<String>, nil] nil when this table is not registered
67
- def columns_for_table(table_name)
68
- model = @model_by_table[table_name.to_s]
69
- @registry[model] if model
76
+ # @param column_name [String]
77
+ # @return [true]
78
+ # @raise [ValidationError] if the table is unmapped or the column is unknown on it
79
+ def validate_table_column!(table_name, column_name)
80
+ model_name = @model_by_table[table_name.to_s]
81
+ unless model_name
82
+ raise ValidationError,
83
+ "Unknown table '#{table_name}'. Cannot validate qualified column " \
84
+ "'#{table_name}.#{column_name}'."
85
+ end
86
+
87
+ validate_column!(model_name, column_name)
70
88
  end
71
89
 
72
90
  # List all known model names.
@@ -1,11 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require 'woods/mcp/http_transport_options'
5
4
  require 'woods/observability/structured_logger'
6
- require 'woods/mcp/bearer_auth'
7
- require 'woods/mcp/origin_guard'
8
- require 'rack/request'
9
5
 
10
6
  module Woods
11
7
  module Console
@@ -21,9 +17,8 @@ module Woods
21
17
  #
22
18
  # config.middleware.use Woods::Console::RackMiddleware, path: '/mcp/console'
23
19
  #
24
- # This mounts 31 console tools at /mcp/console. By default, console_sql and
25
- # console_query are blocked in embedded mode and return an "unsupported" error
26
- # pointing users to enable the flag.
20
+ # This mounts the 9 executable Tier 1 tools at /mcp/console. Explicit
21
+ # read-tool mode registers console_sql and console_query as well.
27
22
  #
28
23
  # == Enabling the feature
29
24
  #
@@ -35,10 +30,12 @@ module Woods
35
30
  # config.console_redacted_columns = %w[api_token password_digest]
36
31
  # end
37
32
  #
38
- # With the flag off, requests to the mounted path return 410 Gone so
39
- # operators can see the endpoint exists but is gated. See
40
- # docs/CONSOLE_MCP_SETUP.md for the full security posture (blocked tables,
41
- # credential scanner, column/EAV redaction, SafeContext rollback).
33
+ # With the flag off, requests — including those at the mounted path —
34
+ # pass through to the app untouched. The Woods railtie mounts this
35
+ # middleware unconditionally (#183), so a host that has not opted in
36
+ # must be completely unaffected. See docs/CONSOLE_MCP_SETUP.md for the
37
+ # full security posture (blocked tables, credential scanner, column/EAV
38
+ # redaction, SafeContext rollback).
42
39
  #
43
40
  # == Enabling read tools (console_sql + console_query)
44
41
  #
@@ -52,9 +49,11 @@ module Woods
52
49
  #
53
50
  # Security posture with embedded_read_tools: true:
54
51
  #
55
- # 1. SqlValidator denylist — console_sql rejects INSERT/UPDATE/DELETE/DROP/TRUNCATE/
56
- # ALTER/CREATE/REPLACE and similar DML/DDL at the string level before any database
57
- # interaction. Only SELECT and WITH...SELECT are allowed.
52
+ # 1. SqlValidator — console_sql rejects INSERT/UPDATE/DELETE/DROP/TRUNCATE/ALTER/CREATE
53
+ # and similar DML/DDL keywords via a denylist at the string level before any database
54
+ # interaction. REPLACE is rejected too, but by the separate allowed-prefix check
55
+ # (only SELECT, WITH, and EXPLAIN may lead a statement), not the denylist — it isn't
56
+ # in SqlValidator::FORBIDDEN_KEYWORDS.
58
57
  #
59
58
  # 2. SafeContext rollback — every request (including console_query) runs inside
60
59
  # a database transaction that is always rolled back on completion. Even if a
@@ -65,77 +64,65 @@ module Woods
65
64
  # ActiveRecord::Base's pool and returns it after the response. No shared
66
65
  # mutable state leaks between requests.
67
66
  #
68
- # These three layers make embedded_read_tools: true safe for read-only workloads.
69
- # If your threat model requires stricter isolation, use the bridge mode instead
70
- # (docs/CONSOLE_MCP_SETUP.md) which runs the executor in a separate process.
67
+ # These three layers define the supported read-tool posture. Keep read tools
68
+ # disabled when the host requires a narrower database capability.
71
69
  #
72
- class RackMiddleware # rubocop:disable Metrics/ClassLength -- mount-local authentication precedes legacy construction
70
+ class RackMiddleware # rubocop:disable Metrics/ClassLength
73
71
  # @param app [#call] The next Rack app in the middleware stack
74
72
  # @param path [String] URL path to mount the MCP endpoint (default: '/mcp/console')
75
- # @param embedded_read_tools [Boolean] Enable sql/query tools in embedded mode (default: false)
76
- # @param unsafe_eval_confirmation [Confirmation, nil] Approval callback for the
77
- # `console_eval` opt-in. Required when `WOODS_CONSOLE_UNSAFE_EVAL=true` (or
78
- # `config.console_unsafe_eval_enabled = true`); the server refuses to boot
79
- # without it. Takes precedence over `config.console_unsafe_eval_confirmation`.
80
- # @param unsafe_eval_audit_log_path [String, Pathname, nil] JSONL audit log
81
- # path for every `console_eval` run. Required on the opt-in path. Takes
82
- # precedence over `config.console_unsafe_eval_audit_log_path`.
83
- def initialize(app, options = {}, **keywords)
84
- raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
85
-
86
- initialize_options(app, **options, **keywords)
87
- end
88
-
89
- def initialize_options(app, path: '/mcp/console', embedded_read_tools: false, origin_policy: nil, # rubocop:disable Metrics/ParameterLists -- preserves legacy eval options alongside the shared HTTP policy
90
- unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil)
73
+ # @param embedded_read_tools [Boolean, #call] Enable sql/query tools in
74
+ # embedded mode (default: false). May be a callable resolved when the
75
+ # server is first built — the railtie passes one because middleware
76
+ # arguments are captured before config/initializers run (#183).
77
+ # @param stateless [Boolean, #call] Use modern stateless Streamable HTTP
78
+ # semantics (default: true). Set false only for compatibility with a
79
+ # client that still requires MCP session IDs.
80
+ # @param unsafe_eval_confirmation [Confirmation, nil] Legacy option retained
81
+ # for compatibility. Passing it fails closed because console_eval is unavailable.
82
+ # @param unsafe_eval_audit_log_path [String, Pathname, nil] Legacy option retained
83
+ # for compatibility. Passing it fails closed because console_eval is unavailable.
84
+ def initialize(app, path: '/mcp/console', embedded_read_tools: false, # rubocop:disable Metrics/ParameterLists
85
+ unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil,
86
+ stateless: true)
91
87
  @app = app
92
88
  @path = path
93
89
  @embedded_read_tools = embedded_read_tools
94
90
  @unsafe_eval_confirmation = unsafe_eval_confirmation
95
91
  @unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
92
+ @stateless = stateless
96
93
  @mutex = Mutex.new
97
94
  @transport = nil
98
- @origin_policy = origin_policy || Woods::MCP::OriginPolicy.new(
99
- allowed_origins: Woods.configuration&.console_mcp_allowed_origins
100
- )
101
- authenticated = Woods::MCP::BearerAuth.new(
102
- method(:handle_request), token: -> { Woods.configuration.console_mcp_token }
103
- )
104
- @guarded_request = Woods::MCP::OriginGuard.new(authenticated, policy: @origin_policy)
105
95
  end
106
- private :initialize_options
107
-
108
- DISABLED_BODY = JSON.generate(
109
- error: 'woods_console_disabled',
110
- message: 'Woods Console MCP is disabled. Set ' \
111
- 'Woods.configuration.console_mcp_enabled = true to enable. ' \
112
- 'See docs/CONSOLE_MCP_SETUP.md for the full security posture.'
113
- ).freeze
114
96
 
115
97
  # Rack interface — intercepts requests at the configured path.
116
98
  #
117
- # Returns 410 Gone when Woods.configuration.console_mcp_enabled is false
118
- # (the default). This keeps the middleware inert on hosts that have
119
- # mounted it but not yet opted into the feature. All other requests at
120
- # non-matching paths pass through to the wrapped app unchanged.
99
+ # The enabled flag is read from Woods.configuration on every request,
100
+ # never captured at construction: the railtie inserts this middleware
101
+ # before config/initializers have run (#183). While
102
+ # `console_mcp_enabled` is false (the default), requests — even at the
103
+ # mounted path — pass through to the wrapped app unchanged, so a host
104
+ # that never opted in is completely unaffected. Requests at
105
+ # non-matching paths always pass through.
121
106
  #
122
107
  # @param env [Hash] Rack environment
123
108
  # @return [Array] Rack response triple
124
109
  def call(env)
125
- return @app.call(env) unless env['PATH_INFO'].start_with?(@path)
126
- return [410, { 'content-type' => 'application/json' }, [DISABLED_BODY]] unless enabled?
110
+ return @app.call(env) unless env['PATH_INFO'].to_s.start_with?(@path)
111
+ return @app.call(env) unless enabled?
127
112
 
128
- @guarded_request.call(env)
113
+ transport = ensure_transport
114
+ request_env = env.dup
115
+ request_env.delete('HTTP_MCP_SESSION_ID') if @stateless_mode
116
+ transport.handle_request(Rack::Request.new(request_env))
129
117
  end
130
118
 
131
119
  private
132
120
 
133
- def handle_request(env)
134
- ensure_transport.handle_request(Rack::Request.new(env))
135
- end
136
-
121
+ # Whether the console is enabled, read from the live configuration on
122
+ # every request. Nil-safe: the railtie mounts this middleware in every
123
+ # app (#183), including hosts that never called Woods.configure.
137
124
  def enabled?
138
- Woods.configuration.console_mcp_enabled
125
+ Woods.configuration&.console_mcp_enabled
139
126
  end
140
127
 
141
128
  # Thread-safe lazy initialization of the MCP server and transport.
@@ -153,8 +140,9 @@ module Woods
153
140
  Rails.application.eager_load!
154
141
 
155
142
  server = build_embedded_server
143
+ @stateless_mode = resolve_deferred(@stateless)
156
144
  @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
157
- server, **@origin_policy.transport_options
145
+ server, stateless: @stateless_mode
158
146
  )
159
147
  server.transport = @transport
160
148
  @transport
@@ -204,7 +192,7 @@ module Woods
204
192
  safe_context: SafeContext.new(pool: ActiveRecord::Base.connection_pool),
205
193
  redacted_columns: Array(config&.console_redacted_columns),
206
194
  redacted_key_values: Array(config&.console_redacted_key_values),
207
- read_tools_enabled: @embedded_read_tools,
195
+ read_tools_enabled: resolve_deferred(@embedded_read_tools),
208
196
  model_tables: introspection[:tables],
209
197
  model_reflections: introspection[:reflections],
210
198
  unsafe_eval_confirmation: @unsafe_eval_confirmation,
@@ -259,6 +247,17 @@ module Woods
259
247
  end
260
248
  end
261
249
 
250
+ # Resolve a constructor option that may have been passed as a callable.
251
+ # The railtie defers option values because middleware arguments are
252
+ # captured at railtie-initializer time — before config/initializers
253
+ # have run (#183).
254
+ #
255
+ # @param value [Object, #call]
256
+ # @return [Object]
257
+ def resolve_deferred(value)
258
+ value.respond_to?(:call) ? value.call : value
259
+ end
260
+
262
261
  def structured_logger
263
262
  @structured_logger ||= Woods::Observability::StructuredLogger.new
264
263
  end
@@ -65,8 +65,7 @@ module Woods
65
65
  case key
66
66
  when 'record' then value.is_a?(Hash) ? ctx.redact(value) : value
67
67
  when 'records' then redact_hash_array(value, ctx)
68
- when 'rows' then redact_positional(value, plan)
69
- when 'values' then redact_positional(value, plan, single_column: plan[:column_count] == 1)
68
+ when 'rows', 'values' then redact_positional(value, plan)
70
69
  when 'associations' then redact_association_map(value, ctx)
71
70
  else value
72
71
  end
@@ -94,8 +93,7 @@ module Woods
94
93
  # `columns` header: the column-name mask plus any EAV key-value rules
95
94
  # resolved to column indexes.
96
95
  def positional_plan(columns, ctx)
97
- { column_count: columns.is_a?(Array) ? columns.length : nil,
98
- mask: positional_mask(columns, ctx),
96
+ { mask: positional_mask(columns, ctx),
99
97
  kv_rules: positional_kv_rules(columns, ctx) }
100
98
  end
101
99
 
@@ -125,42 +123,30 @@ module Woods
125
123
  return [] unless columns.is_a?(Array)
126
124
 
127
125
  names = columns.map(&:to_s)
128
- ctx.redacted_key_values.filter_map { |pattern| positional_kv_rule(names, pattern, ctx) }
126
+ ctx.redacted_key_values.filter_map { |pattern| positional_kv_rule(names, pattern) }
129
127
  end
130
128
 
131
129
  # One resolved rule for one EAV pattern, or nil when the header lacks
132
130
  # either column. Unambiguous headers get the key/value index pair;
133
131
  # duplicated headers get the unconditional mask list.
134
- def positional_kv_rule(names, pattern, ctx)
132
+ def positional_kv_rule(names, pattern)
135
133
  key_idxs = names.each_index.select { |i| names[i] == pattern['key_column'] }
136
134
  val_idxs = names.each_index.select { |i| names[i] == pattern['value_column'] }
137
135
  return nil if key_idxs.empty? || val_idxs.empty?
138
136
  return { mask_idxs: val_idxs } unless key_idxs.one? && val_idxs.one?
139
137
 
140
- { key_idx: key_idxs.first, val_idx: val_idxs.first,
141
- key_matches: ->(value) { sensitive_key?(ctx, value, pattern) } }
142
- end
143
-
144
- def sensitive_key?(ctx, value, pattern)
145
- return ctx.sensitive_key?(value, pattern) if ctx.respond_to?(:sensitive_key?)
146
-
147
- pattern['sensitive_keys'].include?(value.to_s)
138
+ { key_idx: key_idxs.first, val_idx: val_idxs.first, sensitive: pattern['sensitive_keys'] }
148
139
  end
149
140
 
150
141
  # Redact positional row data using a precomputed plan. Handles both
151
- # nested arrays (multi-column pluck, sql/query rows) and single-column
152
- # pluck values. Rails collapses the row for single-column pluck, so an
153
- # Array/Hash value is still one cell and must be redacted as a whole.
154
- def redact_positional(rows, plan, single_column: false)
142
+ # nested arrays (multi-column pluck, sql/query rows) and flat scalar
143
+ # arrays (pluck with a single column — Rails collapses the result).
144
+ def redact_positional(rows, plan)
155
145
  return rows unless rows.is_a?(Array)
156
146
  return rows if plan[:mask].nil? && plan[:kv_rules].empty?
157
147
 
158
148
  rows.map do |row|
159
- if !single_column && row.is_a?(Array)
160
- redact_row(row, plan)
161
- else
162
- redact_scalar(row, plan[:mask])
163
- end
149
+ row.is_a?(Array) ? redact_row(row, plan) : redact_scalar(row, plan[:mask])
164
150
  end
165
151
  end
166
152
 
@@ -170,7 +156,7 @@ module Woods
170
156
  if rule[:mask_idxs]
171
157
  # Ambiguous (duplicated) headers: mask every value-named cell.
172
158
  rule[:mask_idxs].each { |idx| result[idx] = '[REDACTED]' }
173
- elsif rule[:key_matches].call(row[rule[:key_idx]])
159
+ elsif rule[:sensitive].include?(row[rule[:key_idx]].to_s)
174
160
  result[rule[:val_idx]] = '[REDACTED]'
175
161
  end
176
162
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'adapter_family'
4
-
5
3
  # Stub for environments that don't load ActiveRecord
6
4
  unless defined?(ActiveRecord::Rollback)
7
5
  module ActiveRecord
@@ -15,8 +13,10 @@ module Woods
15
13
  #
16
14
  # Safety layers:
17
15
  # - Every query runs inside a transaction that is always rolled back
18
- # - Statement timeout uses `SET LOCAL` so it cannot leak to the next
19
- # pool consumer
16
+ # - Statement timeout cannot leak to the next pool consumer: PostgreSQL
17
+ # uses `SET LOCAL`, which self-discards at transaction end; MySQL has
18
+ # no LOCAL equivalent, so its session-scoped `max_execution_time` is
19
+ # read before the override and restored in an `ensure` instead
20
20
  # - Column redaction replaces sensitive values with "[REDACTED]"
21
21
  #
22
22
  # == What SafeContext does NOT cover
@@ -185,34 +185,22 @@ module Woods
185
185
  apply_key_value_redaction(redacted)
186
186
  end
187
187
 
188
- # Add runtime key types to a request-local redaction view. The original
189
- # context and its execution policy are unchanged.
190
- # @param types [Hash<String, Array<Object>>] Active Record attribute types
191
- # @param raw [Boolean] whether keys are raw database cells
188
+ # Return a SafeContext that keeps THIS context's execution behavior —
189
+ # the same pool (or wrapped connection), statement timeout, and
190
+ # rolled-back transaction — with the given redaction policy applied,
191
+ # so the executor and the renderer can share one policy-complete
192
+ # context. A non-empty kwarg defines that list (the documented
193
+ # configuration surface); an empty kwarg preserves the list this
194
+ # context already carries, so spec-style wiring that configures
195
+ # lists on the context keeps working. See {initialize} for the
196
+ # kwarg shapes.
197
+ #
192
198
  # @return [SafeContext]
193
- def with_key_value_types(types, raw: false)
194
- dup.tap do |context|
195
- context.instance_variable_set(:@key_value_types, types)
196
- context.instance_variable_set(:@raw_key_values, raw)
197
- end
198
- end
199
-
200
- # Match the database spelling and its application representation. If a
201
- # type cannot safely transform a key, protect the value rather than
202
- # returning a value whose sensitivity could not be established.
203
- # @param value [Object] raw or cast EAV key
204
- # @param pattern [Hash] normalized EAV policy
205
- # @return [Boolean]
206
- def sensitive_key?(value, pattern)
207
- sensitive = pattern['sensitive_keys']
208
- return true if sensitive.include?(value.to_s)
209
-
210
- Array(@key_value_types&.fetch(pattern['key_column'], nil)).any? do |type|
211
- alternate = @raw_key_values ? type.deserialize(value) : type.serialize(value)
212
- sensitive.include?(alternate.to_s)
213
- rescue StandardError
214
- true
215
- end
199
+ def with_redaction_policy(redacted_columns: [], redacted_key_values: [])
200
+ policy_columns = redacted_columns.empty? ? @redacted_columns : redacted_columns
201
+ policy_key_values = redacted_key_values.empty? ? @redacted_key_values : redacted_key_values
202
+ SafeContext.new(pool: @pool, timeout_ms: @timeout_ms,
203
+ redacted_columns: policy_columns, redacted_key_values: policy_key_values)
216
204
  end
217
205
 
218
206
  private
@@ -222,7 +210,7 @@ module Woods
222
210
  # clears the thread-local in ensure so a raise mid-block cannot leak
223
211
  # a stale connection reference into the next request on this thread.
224
212
  def run_with_timeout(connection)
225
- previous = Thread.current[LEASED_CONNECTION_KEY]
213
+ previous_lease = Thread.current[LEASED_CONNECTION_KEY]
226
214
  Thread.current[LEASED_CONNECTION_KEY] = connection
227
215
  result = nil
228
216
  connection.transaction do
@@ -236,7 +224,7 @@ module Woods
236
224
  end
237
225
  result
238
226
  ensure
239
- Thread.current[LEASED_CONNECTION_KEY] = previous
227
+ Thread.current[LEASED_CONNECTION_KEY] = previous_lease
240
228
  end
241
229
 
242
230
  def normalize_key_value_patterns(patterns)
@@ -261,7 +249,7 @@ module Woods
261
249
  key_col = pattern['key_column']
262
250
  val_col = pattern['value_column']
263
251
  next unless hash.key?(key_col) && hash.key?(val_col)
264
- next unless sensitive_key?(hash[key_col], pattern)
252
+ next unless pattern['sensitive_keys'].include?(hash[key_col].to_s)
265
253
 
266
254
  hash[val_col] = '[REDACTED]'
267
255
  end
@@ -277,12 +265,21 @@ module Woods
277
265
  # request, background job, etc.). Safe here because every #execute
278
266
  # is wrapped in a transaction.
279
267
  #
280
- # MySQL uses session-scoped `SET max_execution_time` for SELECTs.
281
- # Return a callback that restores the previous value before releasing
282
- # the pooled connection; rollback alone does not reset this setting.
268
+ # MySQL has no per-statement equivalent of `SET LOCAL`: `SET
269
+ # max_execution_time` (applies to SELECT only — DDL and DML statements
270
+ # cannot be time-limited via this variable) is SESSION scope and
271
+ # survives ROLLBACK, so left alone it would leak onto the next request
272
+ # served from the same pooled connection. {#set_mysql_timeout} reads
273
+ # the session's current value first and returns a Proc that restores
274
+ # it; the caller runs that Proc in an +ensure+ around the yielded
275
+ # block.
276
+ #
277
+ # @return [Proc, nil] a restore callback for MySQL, or nil when no
278
+ # restoration is needed (PostgreSQL's LOCAL scope self-discards; an
279
+ # unsupported adapter sets nothing to restore).
283
280
  def set_timeout(connection, timeout_ms = @timeout_ms)
284
281
  adapter = connection.adapter_name.downcase
285
- if AdapterFamily.for(connection) == :mysql
282
+ if adapter.include?('mysql')
286
283
  set_mysql_timeout(connection, timeout_ms)
287
284
  else
288
285
  connection.execute("SET LOCAL statement_timeout = '#{timeout_ms.to_i}ms'")
@@ -297,14 +294,17 @@ module Woods
297
294
  nil
298
295
  end
299
296
 
297
+ # Read MySQL's current session-scoped `max_execution_time`, override
298
+ # it, and return a Proc that restores the value read here. There is no
299
+ # per-statement `SET LOCAL` on MySQL, so the override otherwise
300
+ # outlives this transaction's rollback and bleeds onto whatever the
301
+ # pooled connection serves next.
302
+ #
303
+ # @return [Proc] restores the previous session value
300
304
  def set_mysql_timeout(connection, timeout_ms)
301
- maria = connection.respond_to?(:mariadb?) && connection.mariadb?
302
- variable = maria ? 'max_statement_time' : 'max_execution_time'
303
- timeout = maria ? timeout_ms.to_i / 1000.0 : timeout_ms.to_i
304
- previous_value = Float(connection.select_value("SELECT @@SESSION.#{variable}"))
305
- connection.execute("SET #{variable} = #{timeout}")
306
- previous_value = previous_value.to_i unless maria
307
- -> { connection.execute("SET #{variable} = #{previous_value}") }
305
+ previous_value = connection.select_value('SELECT @@SESSION.max_execution_time').to_i
306
+ connection.execute("SET max_execution_time = #{timeout_ms.to_i}")
307
+ -> { connection.execute("SET max_execution_time = #{previous_value}") }
308
308
  end
309
309
 
310
310
  def warn_timeout_unsupported(adapter, error)