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
@@ -33,20 +33,29 @@ module Woods
33
33
  # @param context_pool [Hash<String, Hash>] Deduplicated unit data keyed by identifier
34
34
  # @param side_effects [Array<Hash>] Async side effects (jobs, mailers)
35
35
  # @param dependency_map [Hash<String, Array<String>>] Unit -> dependency identifiers
36
- # @param token_count [Integer] Estimated total tokens
36
+ # @param token_count [Integer] Estimated tokens of the rendered context output
37
+ # @param budget_exceeded [Boolean] True when even the fully-truncated
38
+ # rendering exceeds the requested budget, so callers see an honest
39
+ # flag instead of a silently over-budget document
37
40
  # @param generated_at [String, nil] ISO8601 timestamp (defaults to now)
38
41
  # rubocop:disable-next Metrics/ParameterLists
39
42
  def initialize(session_id:, steps: [], context_pool: {}, side_effects: [],
40
- dependency_map: {}, token_count: 0, generated_at: nil)
43
+ dependency_map: {}, token_count: 0, budget_exceeded: false, generated_at: nil)
41
44
  @session_id = session_id
42
45
  @steps = steps
43
46
  @context_pool = context_pool
44
47
  @side_effects = side_effects
45
48
  @dependency_map = dependency_map
46
49
  @token_count = token_count
50
+ @budget_exceeded = budget_exceeded
47
51
  @generated_at = generated_at || Time.now.utc.iso8601
48
52
  end
49
53
 
54
+ # @return [Boolean]
55
+ def budget_exceeded?
56
+ @budget_exceeded ? true : false
57
+ end
58
+
50
59
  # Serialize to a JSON-compatible Hash.
51
60
  #
52
61
  # @return [Hash]
@@ -55,6 +64,7 @@ module Woods
55
64
  session_id: @session_id,
56
65
  generated_at: @generated_at,
57
66
  token_count: @token_count,
67
+ budget_exceeded: budget_exceeded?,
58
68
  steps: @steps,
59
69
  context_pool: @context_pool,
60
70
  side_effects: @side_effects,
@@ -78,6 +88,7 @@ module Woods
78
88
  side_effects: data[:side_effects] || [],
79
89
  dependency_map: data[:dependency_map] || {},
80
90
  token_count: data[:token_count] || 0,
91
+ budget_exceeded: data.fetch(:budget_exceeded, false),
81
92
  generated_at: data[:generated_at]
82
93
  )
83
94
  end
@@ -148,13 +159,13 @@ module Woods
148
159
 
149
160
  # Render as LLM-consumable XML context.
150
161
  #
151
- # Follows the format from docs/CONTEXT_AND_CHUNKING.md.
162
+ # Follows the format from `git log --follow -- docs/design/CONTEXT_AND_CHUNKING.md`.
152
163
  #
153
164
  # @return [String]
154
165
  # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
155
166
  def to_context
156
167
  lines = []
157
- header = "<session_context session_id=\"#{@session_id}\" requests=\"#{@steps.size}\" " \
168
+ header = "<session_context session_id=\"#{escape_attribute(@session_id)}\" requests=\"#{@steps.size}\" " \
158
169
  "tokens=\"#{@token_count}\" units=\"#{@context_pool.size}\">"
159
170
  lines << header
160
171
 
@@ -173,8 +184,10 @@ module Woods
173
184
  @context_pool.each do |identifier, unit|
174
185
  type = unit[:type] || 'unknown'
175
186
  file_path = unit[:file_path] || 'unknown'
176
- lines << %(<unit identifier="#{identifier}" type="#{type}" file="#{file_path}">)
177
- lines << (unit[:source_code] || '# source not available')
187
+ tag = %(<unit identifier="#{escape_attribute(identifier)}" type="#{escape_attribute(type)}" ) \
188
+ "file=\"#{escape_attribute(file_path)}\">"
189
+ lines << tag
190
+ lines << escape_content(unit[:source_code] || '# source not available')
178
191
  lines << '</unit>'
179
192
  end
180
193
 
@@ -200,6 +213,39 @@ module Woods
200
213
  lines.join("\n")
201
214
  end
202
215
 
216
+ private
217
+
218
+ # Escape a value for use inside a double-quoted attribute in
219
+ # {#to_context}'s markup.
220
+ #
221
+ # This is prompt-injection hardening, not XML conformance: `session_id`
222
+ # comes from the client-controlled X-Trace-Session header, and unit
223
+ # identifiers/paths flow through from extracted code. Unescaped, a
224
+ # crafted value (e.g. `"><injected_tag>`) breaks out of the attribute
225
+ # and inserts sibling markup into context an LLM later reads as trusted
226
+ # structure.
227
+ #
228
+ # @param value [Object]
229
+ # @return [String]
230
+ def escape_attribute(value)
231
+ value.to_s
232
+ .gsub('&', '&amp;')
233
+ .gsub('"', '&quot;')
234
+ .gsub('<', '&lt;')
235
+ .gsub('>', '&gt;')
236
+ end
237
+
238
+ # Escape a value for use as element content (between tags) in
239
+ # {#to_context}'s markup. Same prompt-injection motivation as
240
+ # {#escape_attribute}; content only needs `&` and `<` escaped since
241
+ # there's no attribute quote to break out of.
242
+ #
243
+ # @param value [Object]
244
+ # @return [String]
245
+ def escape_content(value)
246
+ value.to_s.gsub('&', '&amp;').gsub('<', '&lt;')
247
+ end
248
+
203
249
  # @api private
204
250
  def self.deep_symbolize_keys(obj)
205
251
  case obj
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/cache'
4
+ require 'securerandom'
5
+
6
+ module Woods
7
+ class Error < StandardError; end unless defined?(Woods::Error)
8
+
9
+ module SessionTracer
10
+ # Uncached, per-key routed coordination operations for Solid Cache 1.0.
11
+ class SolidCacheCoordination # rubocop:disable Metrics/ClassLength
12
+ FAILURE = Object.new.freeze
13
+
14
+ class BackendError < Woods::Error; end
15
+
16
+ def initialize(cache)
17
+ @cache = cache
18
+ end
19
+
20
+ def read(name, options = nil)
21
+ with_operation(:read, name, options) do |key, merged_options, event|
22
+ raw = routed_read(key, :read_entry)
23
+ entry = @cache.send(:deserialize_entry, raw, **merged_options)
24
+ if entry&.expired?
25
+ delete_expired_entry(key, raw)
26
+ entry = nil
27
+ end
28
+ event[:hit] = !entry.nil? if event
29
+ entry&.value
30
+ end
31
+ rescue ActiveSupport::Cache::DeserializationError
32
+ nil
33
+ end
34
+
35
+ def write(name, value, **options)
36
+ with_operation(:write, name, options) do |key, merged_options, _event|
37
+ entry = cache_entry(name, value, merged_options)
38
+ payload = @cache.send(:serialize_entry, entry, **merged_options)
39
+ result = @cache.send(:entry_write, key, payload)
40
+ backend_result!(result, :write, name, nil_is_failure: true)
41
+ end
42
+ end
43
+
44
+ def delete(name, options = nil)
45
+ with_operation(:delete, name, options) do |key, _merged_options, _event|
46
+ routed_delete(key, :delete_entry).positive?
47
+ end
48
+ end
49
+
50
+ def increment(name, amount = 1, options = nil)
51
+ with_operation(:increment, name, options, amount: amount) do |key, merged_options, _event|
52
+ payload = @cache.send(:entry_lock_and_write, key) do |raw|
53
+ entry = @cache.send(:adjusted_entry, raw, amount, merged_options)
54
+ @cache.send(:serialize_entry, entry, **merged_options)
55
+ end
56
+ backend_result!(payload, :increment, name, nil_is_failure: true)
57
+ @cache.send(:deserialize_entry, payload, **merged_options).value
58
+ end
59
+ end
60
+
61
+ def write_if_absent(name, value, **options)
62
+ with_operation(:write, name, options) do |key, merged_options, _event|
63
+ entry = cache_entry(name, value, merged_options, version: "woods-cas:#{SecureRandom.hex(16)}")
64
+ payload = @cache.send(:serialize_entry, entry, **merged_options)
65
+ result = routed_write(key, :write_entry) do
66
+ entry_class = ::SolidCache::Entry
67
+ inserted = attempt_insert?(entry_class, key, payload)
68
+ if !inserted && clear_expired_entry?(entry_class, key, merged_options)
69
+ inserted = attempt_insert?(entry_class, key, payload)
70
+ end
71
+ @cache.send(:track_writes, 1) if inserted
72
+ inserted
73
+ end
74
+ backend_result!(result, :write_if_absent, name)
75
+ end
76
+ end
77
+
78
+ def delete_if_equal(name, expected)
79
+ with_operation(:delete, name, nil) do |key, merged_options, _event|
80
+ result = routed_write(key, :delete_entry) do
81
+ locked_compare_and_delete(key) do |raw_value|
82
+ entry = @cache.send(:deserialize_entry, raw_value, **merged_options)
83
+ entry && !entry.expired? && entry.value == expected
84
+ end
85
+ end
86
+ backend_result!(result, :delete_if_equal, name)
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def with_operation(operation, name, options, event_options = nil)
93
+ merged_options = @cache.send(:merged_options, options)
94
+ key = @cache.send(:normalize_key, name, merged_options)
95
+ instrument_options = event_options || merged_options
96
+
97
+ @cache.send(:bypass_local_cache) do
98
+ @cache.send(:instrument, operation, key, instrument_options) do |event|
99
+ yield key, merged_options, event
100
+ end
101
+ end
102
+ rescue NoMethodError => e
103
+ raise BackendError,
104
+ "Solid Cache private API unavailable for #{operation} #{name.inspect} (#{e.message}); " \
105
+ 'this Solid Cache version is not supported by Woods session tracing'
106
+ end
107
+
108
+ # Row-locked compare-and-delete on the raw stored payload. The block
109
+ # decides against the exact bytes read under lock, so a concurrent
110
+ # replacement can never be deleted by mistake.
111
+ def locked_compare_and_delete(key)
112
+ entry_class = ::SolidCache::Entry
113
+ key_hash = entry_class.send(:key_hash_for, key)
114
+ entry_class.transaction do
115
+ raw_key, raw_value = entry_class.lock.where(key_hash: key_hash).pick(:key, :value)
116
+ next false unless raw_key == key
117
+ next false unless yield(raw_value)
118
+
119
+ entry_class.delete_by_key(key)
120
+ true
121
+ end
122
+ end
123
+
124
+ # Conditionally remove an expired entry observed by a read: only the
125
+ # exact expired payload is deleted, never a fresh replacement.
126
+ def delete_expired_entry(key, observed_raw)
127
+ routed_write(key, :delete_entry) do
128
+ locked_compare_and_delete(key) { |raw_value| raw_value == observed_raw }
129
+ end
130
+ end
131
+
132
+ def attempt_insert?(entry_class, key, payload)
133
+ inserted?(entry_class, insert_entry(entry_class, key, payload), key, payload)
134
+ end
135
+
136
+ # Treat a physically-present but logically-expired row as absent for
137
+ # conditional writes by conditionally removing the exact expired payload.
138
+ def clear_expired_entry?(entry_class, key, merged_options)
139
+ raw = entry_class.read(key)
140
+ return false unless raw
141
+
142
+ entry = @cache.send(:deserialize_entry, raw, **merged_options)
143
+ return false unless entry&.expired?
144
+
145
+ locked_compare_and_delete(key) { |raw_value| raw_value == raw }
146
+ rescue ActiveSupport::Cache::DeserializationError
147
+ false
148
+ end
149
+
150
+ def cache_entry(name, value, options, version: @cache.send(:normalize_version, name, options))
151
+ ActiveSupport::Cache::Entry.new(value, **options, version: version)
152
+ end
153
+
154
+ def insert_entry(entry_class, key, payload)
155
+ attributes = entry_class.send(:add_key_hash_and_byte_size, [{ key: key, value: payload }])
156
+ entry_class.insert_all(attributes, unique_by: entry_class.send(:upsert_unique_by))
157
+ end
158
+
159
+ def inserted?(entry_class, result, key, payload)
160
+ affected_rows = result.affected_rows if result.respond_to?(:affected_rows)
161
+ return affected_rows.positive? unless affected_rows.nil?
162
+ return result.rows.any? if entry_class.connection.supports_insert_returning?
163
+
164
+ entry_class.read(key) == payload
165
+ end
166
+
167
+ def routed_read(key, failsafe)
168
+ result = @cache.send(:reading_key, key, failsafe: failsafe, failsafe_returning: FAILURE) do
169
+ ::SolidCache::Entry.read(key)
170
+ end
171
+ backend_result!(result, :read, key)
172
+ end
173
+
174
+ def routed_write(key, failsafe, &block)
175
+ @cache.send(:writing_key, key, failsafe: failsafe, failsafe_returning: FAILURE, &block)
176
+ end
177
+
178
+ def routed_delete(key, failsafe)
179
+ result = routed_write(key, failsafe) { ::SolidCache::Entry.delete_by_key(key) }
180
+ backend_result!(result, :delete, key)
181
+ end
182
+
183
+ def backend_result!(result, operation, key, nil_is_failure: false)
184
+ failed = result.equal?(FAILURE) || (nil_is_failure && result.nil?)
185
+ return result unless failed
186
+
187
+ raise BackendError,
188
+ "Solid Cache routed #{operation} failed for #{key.inspect}; inspect the cache backend error handler"
189
+ end
190
+ end
191
+ end
192
+ end