woods 1.6.1 → 2.0.0.beta2

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  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 +655 -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 +455 -0
  19. data/docs/INTERNALS.md +418 -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 +213 -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 +80 -11
  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 +171 -2
  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/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -7,6 +7,8 @@ module Woods
7
7
  private
8
8
 
9
9
  def build_fqn(name, namespace_stack)
10
+ return name.delete_prefix('::') if name.start_with?('::')
11
+
10
12
  if namespace_stack.empty?
11
13
  name
12
14
  else
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'set'
4
+ require_relative '../dependency_graph'
4
5
 
5
6
  module Woods
6
7
  module RubyAnalyzer
@@ -89,16 +90,25 @@ module Woods
89
90
  lines << ' end'
90
91
  end
91
92
 
92
- # Render edges
93
+ # Render edges. `to_h` has emitted `[{target:, via:}]` hashes since the
94
+ # via migration; iterating them as bare targets made `nodes.key?` false
95
+ # for every edge, so the map rendered nodes and nothing else (EXTB-6).
96
+ # Legacy bare-string edges still load — {DependencyGraph.normalize_edges}
97
+ # accepts both shapes.
93
98
  seen_edges = Set.new
94
99
  edges.each do |source, targets|
95
- Array(targets).each do |target|
96
- next unless nodes.key?(target)
100
+ Woods::DependencyGraph.normalize_edges(Array(targets)).each do |edge|
101
+ target = edge[:target]
102
+ next unless target && nodes.key?(target)
97
103
 
98
104
  edge_key = "#{sanitize_id(source)}->#{sanitize_id(target)}"
99
105
  next unless seen_edges.add?(edge_key)
100
106
 
101
- lines << " #{sanitize_id(source)} --> #{sanitize_id(target)}"
107
+ lines << if edge[:via]
108
+ " #{sanitize_id(source)} -->|#{edge[:via]}| #{sanitize_id(target)}"
109
+ else
110
+ " #{sanitize_id(source)} --> #{sanitize_id(target)}"
111
+ end
102
112
  end
103
113
  end
104
114
 
@@ -63,7 +63,7 @@ module Woods
63
63
  body_children = (node.children || [])[body_offset..] || []
64
64
 
65
65
  visibility_tracker = VisibilityTracker.new
66
- inner_ns = namespace_stack + [name]
66
+ inner_ns = fqn.split('::')
67
67
 
68
68
  body_children.each do |child|
69
69
  next unless child.is_a?(Ast::Node)
@@ -21,7 +21,10 @@ module Woods
21
21
  #
22
22
  # @yield Block to trace
23
23
  # @return [Array<Hash>] Collected trace events
24
+ # @raise [ArgumentError] if no block is given
24
25
  def self.record(&block)
26
+ raise ArgumentError, 'block required' unless block
27
+
25
28
  traces = []
26
29
 
27
30
  trace = TracePoint.new(:call, :return) do |tp|
@@ -21,11 +21,14 @@ module Woods
21
21
  class << self
22
22
  # Analyze Ruby source files and produce ExtractedUnit objects.
23
23
  #
24
- # @param paths [Array<String>] File paths or directories to analyze
24
+ # @param paths [Array<String>, nil] File paths or directories to analyze
25
+ # @param sources [Hash{String => String}, nil] Pre-read source keyed by
26
+ # absolute file path. This lets callers analyze one consistent source
27
+ # snapshot rather than re-reading files while they may be changing.
25
28
  # @param trace_data [Array<Hash>, nil] Optional runtime trace data for enrichment
26
29
  # @return [Array<ExtractedUnit>] All extracted units
27
- def analyze(paths:, trace_data: nil)
28
- files = discover_files(paths)
30
+ def analyze(paths: nil, sources: nil, trace_data: nil)
31
+ files = sources ? sources.keys.sort : discover_files(Array(paths))
29
32
  return [] if files.empty?
30
33
 
31
34
  parser = Ast::Parser.new
@@ -36,7 +39,7 @@ module Woods
36
39
  units = []
37
40
 
38
41
  files.each do |file_path|
39
- source = read_file(file_path)
42
+ source = source_for(file_path, sources)
40
43
  next unless source
41
44
 
42
45
  units.concat(class_analyzer.analyze(source: source, file_path: file_path))
@@ -73,12 +76,25 @@ module Woods
73
76
  files.uniq
74
77
  end
75
78
 
79
+ def source_for(file_path, sources)
80
+ return sources.fetch(file_path) if sources
81
+
82
+ read_file(file_path)
83
+ end
84
+
76
85
  # Read a file safely, returning nil on failure.
77
86
  #
87
+ # Ruby source defaults to UTF-8, so the read is pinned to UTF-8
88
+ # rather than the process default external encoding. Under LANG=C
89
+ # (US-ASCII) a bare File.read tags multibyte source, such as an em
90
+ # dash in a comment, as invalid and JSON generation raises
91
+ # Encoding::InvalidByteSequenceError out of the analysis.
92
+ #
78
93
  # @param path [String] File path
79
94
  # @return [String, nil] File contents or nil
80
95
  def read_file(path)
81
- File.read(path)
96
+ content = File.read(path, encoding: Encoding::UTF_8)
97
+ content.valid_encoding? ? content : content.scrub
82
98
  rescue StandardError
83
99
  nil
84
100
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'fileutils'
5
+ require 'tempfile'
5
6
  require_relative 'store'
6
7
 
7
8
  module Woods
@@ -11,22 +12,34 @@ module Woods
11
12
  # Sessions are stored as individual files in a configurable directory:
12
13
  # {base_dir}/{session_id}.jsonl
13
14
  #
14
- # Append-only with file locking for concurrency safety. Zero external dependencies.
15
+ # Bounded JSONL history replaced atomically under a cross-process file lock.
16
+ # Zero external dependencies.
15
17
  #
16
18
  # @example
17
19
  # store = FileStore.new(base_dir: "tmp/woods/sessions")
18
20
  # store.record("abc123", { controller: "PostsController", action: "create" })
19
21
  # store.read("abc123") # => [{ "controller" => "PostsController", ... }]
20
22
  #
21
- class FileStore < Store
23
+ class FileStore < Store # rubocop:disable Metrics/ClassLength
24
+ DEFAULT_MAX_SESSIONS = 1_000
25
+ DEFAULT_MAX_REQUESTS = 1_000
26
+
22
27
  # @param base_dir [String] Directory for session JSONL files
23
- def initialize(base_dir:)
28
+ def initialize(base_dir:, ttl: nil, max_sessions: DEFAULT_MAX_SESSIONS,
29
+ max_requests_per_session: DEFAULT_MAX_REQUESTS, clock: -> { Time.now })
24
30
  super()
31
+ validate_limit!(:max_sessions, max_sessions)
32
+ validate_limit!(:max_requests_per_session, max_requests_per_session)
25
33
  @base_dir = base_dir
34
+ @ttl = ttl
35
+ @max_sessions = max_sessions
36
+ @max_requests_per_session = max_requests_per_session
37
+ @clock = clock
38
+ @mutex = Mutex.new
26
39
  FileUtils.mkdir_p(@base_dir)
27
40
  end
28
41
 
29
- # Append a request record to a session's JSONL file.
42
+ # Add a request record to a bounded session JSONL history.
30
43
  #
31
44
  # Uses file locking (LOCK_EX) for concurrency safety.
32
45
  #
@@ -34,12 +47,22 @@ module Woods
34
47
  # @param request_data [Hash] Request metadata to store
35
48
  # @return [void]
36
49
  def record(session_id, request_data)
37
- path = session_path(session_id)
38
50
  line = "#{JSON.generate(request_data)}\n"
39
51
 
40
- File.open(path, 'a') do |f|
41
- f.flock(File::LOCK_EX)
42
- f.write(line)
52
+ with_store_lock do
53
+ path = migrate_legacy_session!(session_id)
54
+ # Explicit UTF-8 on every read in this file: the JSONL is written as
55
+ # raw UTF-8 bytes, so a bare read tags the lines with the process
56
+ # default external encoding and one non-ASCII request path (a
57
+ # localized route) breaks `record`'s history join, `read`,
58
+ # `sessions`, and the `session_trace` MCP tool under LANG=C (R1-1).
59
+ lines = if File.exist?(path)
60
+ File.readlines(path, encoding: Encoding::UTF_8).last(@max_requests_per_session - 1)
61
+ else
62
+ []
63
+ end
64
+ atomic_replace(path, (lines << line).join)
65
+ prune_sessions!
43
66
  end
44
67
  end
45
68
 
@@ -48,10 +71,18 @@ module Woods
48
71
  # @param session_id [String] The session identifier
49
72
  # @return [Array<Hash>] Request records, oldest first
50
73
  def read(session_id)
51
- path = session_path(session_id)
52
- return [] unless File.exist?(path)
74
+ lines = with_store_lock do
75
+ path = migrate_legacy_session!(session_id)
76
+ return [] unless File.exist?(path)
77
+
78
+ if expired?(path)
79
+ FileUtils.rm_f(path)
80
+ return []
81
+ end
53
82
 
54
- File.readlines(path).filter_map do |line|
83
+ File.readlines(path, encoding: Encoding::UTF_8)
84
+ end
85
+ lines.filter_map do |line|
55
86
  stripped = line.strip
56
87
  next if stripped.empty?
57
88
 
@@ -66,11 +97,14 @@ module Woods
66
97
  # @param limit [Integer] Maximum number of sessions to return
67
98
  # @return [Array<Hash>] Session summaries
68
99
  def sessions(limit: 20)
69
- pattern = File.join(@base_dir, '*.jsonl')
70
- files = Dir.glob(pattern).sort_by { |f| -File.mtime(f).to_f }
100
+ files = with_store_lock do
101
+ migrate_legacy_sessions!
102
+ prune_expired!
103
+ session_files.sort_by { |file| -File.mtime(file).to_f }.first(limit)
104
+ end
71
105
 
72
- files.first(limit).map do |file|
73
- session_id = File.basename(file, '.jsonl')
106
+ files.map do |file|
107
+ session_id = restore_session_id(File.basename(file, '.jsonl'))
74
108
  session_summary(session_id, read(session_id))
75
109
  end
76
110
  end
@@ -80,16 +114,17 @@ module Woods
80
114
  # @param session_id [String] The session identifier
81
115
  # @return [void]
82
116
  def clear(session_id)
83
- path = session_path(session_id)
84
- FileUtils.rm_f(path)
117
+ with_store_lock do
118
+ FileUtils.rm_f(session_path(session_id))
119
+ FileUtils.rm_f(legacy_session_path(session_id))
120
+ end
85
121
  end
86
122
 
87
123
  # Remove all session data.
88
124
  #
89
125
  # @return [void]
90
126
  def clear_all
91
- pattern = File.join(@base_dir, '*.jsonl')
92
- Dir.glob(pattern).each { |f| File.delete(f) }
127
+ with_store_lock { session_files.each { |file| File.delete(file) } }
93
128
  end
94
129
 
95
130
  private
@@ -99,6 +134,90 @@ module Woods
99
134
  def session_path(session_id)
100
135
  File.join(@base_dir, "#{sanitize_session_id(session_id)}.jsonl")
101
136
  end
137
+
138
+ def session_files
139
+ Dir.glob(File.join(@base_dir, '*.jsonl'))
140
+ end
141
+
142
+ def with_store_lock
143
+ @mutex.synchronize do
144
+ File.open(File.join(@base_dir, '.woods-session-store.lock'), File::RDWR | File::CREAT, 0o600) do |lock|
145
+ lock.flock(File::LOCK_EX)
146
+ yield
147
+ end
148
+ end
149
+ end
150
+
151
+ def legacy_session_path(session_id)
152
+ raw = session_id.to_s
153
+ return unless raw.match?(/\A[a-zA-Z0-9_-]+\z/)
154
+
155
+ File.join(@base_dir, "#{raw}.jsonl")
156
+ end
157
+
158
+ def migrate_legacy_session!(session_id)
159
+ target = session_path(session_id)
160
+ legacy = legacy_session_path(session_id)
161
+ return target unless legacy && File.exist?(legacy)
162
+
163
+ if File.exist?(target)
164
+ atomic_replace(target,
165
+ File.read(legacy, encoding: Encoding::UTF_8) +
166
+ File.read(target, encoding: Encoding::UTF_8))
167
+ FileUtils.rm_f(legacy)
168
+ else
169
+ File.rename(legacy, target)
170
+ fsync_parent
171
+ end
172
+ target
173
+ end
174
+
175
+ def migrate_legacy_sessions!
176
+ session_files.each do |path|
177
+ basename = File.basename(path, '.jsonl')
178
+ migrate_legacy_session!(basename) unless basename.start_with?('b64.')
179
+ end
180
+ end
181
+
182
+ def atomic_replace(path, content)
183
+ temp = Tempfile.new([".#{File.basename(path)}-", '.tmp'], @base_dir)
184
+ temp.chmod(0o600)
185
+ temp.write(content)
186
+ temp.flush
187
+ temp.fsync
188
+ temp.close
189
+ File.rename(temp.path, path)
190
+ fsync_parent
191
+ ensure
192
+ temp&.close
193
+ temp&.unlink
194
+ end
195
+
196
+ def fsync_parent
197
+ File.open(@base_dir, File::RDONLY, &:fsync)
198
+ rescue Errno::EINVAL, Errno::ENOTSUP, Errno::EISDIR
199
+ nil
200
+ end
201
+
202
+ def expired?(path)
203
+ @ttl && @clock.call >= File.mtime(path) + @ttl
204
+ end
205
+
206
+ def prune_expired!
207
+ session_files.each { |file| FileUtils.rm_f(file) if expired?(file) }
208
+ end
209
+
210
+ def prune_sessions!
211
+ prune_expired!
212
+ stale = session_files.sort_by { |file| -File.mtime(file).to_f }.drop(@max_sessions)
213
+ stale.each { |file| FileUtils.rm_f(file) }
214
+ end
215
+
216
+ def validate_limit!(name, value)
217
+ return if value.is_a?(Integer) && value.positive?
218
+
219
+ raise ArgumentError, "#{name} must be a positive Integer"
220
+ end
102
221
  end
103
222
  end
104
223
  end
@@ -85,7 +85,7 @@ module Woods
85
85
  # @param env [Hash] Rack environment
86
86
  # @param status [Integer] HTTP response status
87
87
  # @param duration_ms [Integer] Request duration in milliseconds
88
- # rubocop:disable Metrics/MethodLength
88
+ # rubocop:disable-next Metrics/MethodLength
89
89
  def record_request(env, status, duration_ms)
90
90
  path = env['PATH_INFO'] || ''
91
91
  return if excluded?(path)
@@ -116,7 +116,6 @@ module Woods
116
116
 
117
117
  @store.record(session_id, request_data)
118
118
  end
119
- # rubocop:enable Metrics/MethodLength
120
119
 
121
120
  # Extract session ID: X-Trace-Session header first, then session cookie, then fallback.
122
121
  #
@@ -1,14 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'time'
4
5
  require_relative 'store'
5
6
 
7
+ # Woods::SessionTracerError is defined in the entry point; required here so
8
+ # this file can be loaded directly. Without it a narrow entry point (a custom
9
+ # boot, a script) turned the documented "add `gem \"redis\"`" error into a
10
+ # NameError naming a missing constant (INF-8).
11
+ require 'woods'
12
+
6
13
  module Woods
7
14
  module SessionTracer
8
- # Redis-backed session store using Lists.
15
+ # Redis-backed session store using Lists plus a recency ZSET.
9
16
  #
10
17
  # Each session is stored as a Redis List keyed `woods:session:{id}`.
11
- # RPUSH per request for append-only ordering. Native TTL for automatic cleanup.
18
+ # RPUSH per request for append-only ordering. Native TTL for automatic
19
+ # cleanup. The `woods:sessions` index is a ZSET scored by each session's
20
+ # last request timestamp, so eviction touches O(log n) members instead
21
+ # of re-reading every session's history (audit P4). A legacy SET left by
22
+ # an earlier version is migrated on the first record.
12
23
  #
13
24
  # Requires the `redis` gem at runtime.
14
25
  #
@@ -18,11 +29,37 @@ module Woods
18
29
  #
19
30
  class RedisStore < Store
20
31
  KEY_PREFIX = 'woods:session:'
32
+ # Recency index: ZSET of session_id => last-request epoch. Was a SET
33
+ # before the zset migration; legacy deployments migrate through the
34
+ # atomic script below.
21
35
  SESSIONS_KEY = 'woods:sessions'
36
+ DEFAULT_MAX_SESSIONS = 1_000
37
+ DEFAULT_MAX_REQUESTS = 1_000
38
+
39
+ # Recency-index update for one record, executed server-side as one
40
+ # atomic step: type check, legacy SET transfer, insertion of the
41
+ # current member. Redis runs a script without interleaving, so two
42
+ # writers racing a legacy index can neither observe the SET with one
43
+ # writer and erase the other's members, nor fail with WRONGTYPE after
44
+ # the other converted; a writer arriving on an already-converted (or
45
+ # fresh) key takes the plain ZADD path of the same script.
46
+ INDEX_UPDATE_SCRIPT = <<~LUA
47
+ local kind = redis.call('TYPE', KEYS[1]).ok
48
+ if kind == 'set' then
49
+ local legacy = redis.call('SMEMBERS', KEYS[1])
50
+ redis.call('DEL', KEYS[1])
51
+ for _, member in ipairs(legacy) do
52
+ redis.call('ZADD', KEYS[1], 0, member)
53
+ end
54
+ end
55
+ redis.call('ZADD', KEYS[1], tonumber(ARGV[1]), ARGV[2])
56
+ return 1
57
+ LUA
22
58
 
23
59
  # @param redis [Redis] A Redis client instance
24
60
  # @param ttl [Integer, nil] Time-to-live in seconds for session keys (nil = no expiry)
25
- def initialize(redis:, ttl: nil)
61
+ def initialize(redis:, ttl: nil, max_sessions: DEFAULT_MAX_SESSIONS,
62
+ max_requests_per_session: DEFAULT_MAX_REQUESTS)
26
63
  super()
27
64
  unless defined?(::Redis)
28
65
  raise SessionTracerError, 'The redis gem is required for RedisStore. Add `gem "redis"` to your Gemfile.'
@@ -30,6 +67,8 @@ module Woods
30
67
 
31
68
  @redis = redis
32
69
  @ttl = ttl
70
+ @max_sessions = max_sessions
71
+ @max_requests_per_session = max_requests_per_session
33
72
  end
34
73
 
35
74
  # Append a request record to a session's Redis List.
@@ -40,8 +79,10 @@ module Woods
40
79
  def record(session_id, request_data)
41
80
  key = session_key(session_id)
42
81
  @redis.rpush(key, JSON.generate(request_data))
82
+ @redis.ltrim(key, -@max_requests_per_session, -1)
43
83
  @redis.expire(key, @ttl) if @ttl
44
- @redis.sadd(SESSIONS_KEY, session_id)
84
+ index_session(session_id, request_data)
85
+ prune_sessions(session_id)
45
86
  end
46
87
 
47
88
  # Read all request records for a session.
@@ -62,18 +103,28 @@ module Woods
62
103
  # @param limit [Integer] Maximum number of sessions to return
63
104
  # @return [Array<Hash>] Session summaries
64
105
  def sessions(limit: 20)
65
- all_ids = @redis.smembers(SESSIONS_KEY)
106
+ all_ids = @redis.zrange(SESSIONS_KEY, 0, -1)
66
107
 
67
108
  # Filter to sessions that still have data (TTL may have expired)
68
109
  active = all_ids.select { |id| @redis.exists?(session_key(id)) }
69
110
 
70
- # Remove expired session IDs from the set
111
+ # Remove expired session IDs from the index
71
112
  expired = all_ids - active
72
- expired.each { |id| @redis.srem(SESSIONS_KEY, id) } if expired.any?
113
+ expired.each { |id| @redis.zrem(SESSIONS_KEY, id) } if expired.any?
73
114
 
74
- active.first(limit).map do |session_id|
75
- session_summary(session_id, read(session_id))
76
- end
115
+ # Redis sorted sets order by score, but a summary's last_request is
116
+ # the payload timestamp of the session's last record — the same
117
+ # source the score is derived from. Ordering the built summaries by
118
+ # that field keeps the exact ordering contract (#218 / B-105) even
119
+ # where a clock crept between score and payload.
120
+ #
121
+ # This still reads every active session rather than `limit` of them,
122
+ # as before; the zset made *eviction* the O(log n) path.
123
+ active
124
+ .map { |session_id| session_summary(session_id, read(session_id)) }
125
+ .sort_by { |summary| recency_key(summary) }
126
+ .reverse
127
+ .first(limit)
77
128
  end
78
129
 
79
130
  # Remove all data for a single session.
@@ -82,14 +133,14 @@ module Woods
82
133
  # @return [void]
83
134
  def clear(session_id)
84
135
  @redis.del(session_key(session_id))
85
- @redis.srem(SESSIONS_KEY, session_id)
136
+ @redis.zrem(SESSIONS_KEY, session_id)
86
137
  end
87
138
 
88
139
  # Remove all session data.
89
140
  #
90
141
  # @return [void]
91
142
  def clear_all
92
- all_ids = @redis.smembers(SESSIONS_KEY)
143
+ all_ids = @redis.zrange(SESSIONS_KEY, 0, -1)
93
144
  all_ids.each { |id| @redis.del(session_key(id)) }
94
145
  @redis.del(SESSIONS_KEY)
95
146
  end
@@ -101,6 +152,65 @@ module Woods
101
152
  def session_key(session_id)
102
153
  "#{KEY_PREFIX}#{sanitize_session_id(session_id)}"
103
154
  end
155
+
156
+ # Record the session in the recency index, scored by the request's own
157
+ # timestamp so eviction order matches the last-request ordering
158
+ # {#sessions} reports. The whole update — a legacy SET migration
159
+ # included — runs as one atomic script (see {INDEX_UPDATE_SCRIPT}).
160
+ #
161
+ # @param session_id [String]
162
+ # @param request_data [Hash]
163
+ # @return [void]
164
+ def index_session(session_id, request_data)
165
+ @redis.eval(INDEX_UPDATE_SCRIPT, keys: [SESSIONS_KEY], argv: [recency_score(request_data), session_id])
166
+ end
167
+
168
+ # @param request_data [Hash]
169
+ # @return [Float] Request timestamp as an epoch, or write time
170
+ def recency_score(request_data)
171
+ timestamp = request_data['timestamp'] if request_data.is_a?(Hash)
172
+ timestamp.is_a?(String) ? Time.iso8601(timestamp).to_f : Time.now.to_f
173
+ rescue ArgumentError
174
+ Time.now.to_f
175
+ end
176
+
177
+ # Evict the oldest sessions once the recency index overflows
178
+ # `@max_sessions`.
179
+ #
180
+ # The ZSET is scored by last request timestamp (see {#index_session}),
181
+ # so the `overflow + 1` lowest-scoring members contain the oldest
182
+ # `overflow` sessions other than the one just recorded. That matches
183
+ # the FileStore twin's oldest-by-mtime eviction without reading any
184
+ # session's history — the pre-zset implementation re-read every
185
+ # candidate on every record once the cap was reached (audit P4).
186
+ #
187
+ # @param current_session_id [String] never evicted, even if oldest
188
+ # @return [void]
189
+ def prune_sessions(current_session_id)
190
+ overflow = @redis.zcard(SESSIONS_KEY) - @max_sessions
191
+ return unless overflow.positive?
192
+
193
+ victims = @redis.zrange(SESSIONS_KEY, 0, overflow)
194
+ .reject { |id| id == current_session_id }
195
+ .first(overflow)
196
+
197
+ victims.each do |id|
198
+ @redis.del(session_key(id))
199
+ @redis.zrem(SESSIONS_KEY, id)
200
+ end
201
+ end
202
+
203
+ # Sort key for {#sessions}: most recent request first.
204
+ #
205
+ # Timestamps are ISO-8601 strings, which sort correctly as strings. A
206
+ # session with no requests (all entries expired mid-read) sorts last
207
+ # rather than raising on a nil comparison.
208
+ #
209
+ # @param summary [Hash]
210
+ # @return [String]
211
+ def recency_key(summary)
212
+ summary['last_request'].to_s
213
+ end
104
214
  end
105
215
  end
106
216
  end