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
@@ -3,6 +3,9 @@
3
3
  require 'json'
4
4
  require 'time'
5
5
  require 'digest'
6
+ require 'fileutils'
7
+ require_relative '../atomic_file'
8
+ require_relative '../payload_store'
6
9
 
7
10
  module Woods
8
11
  module Temporal
@@ -12,8 +15,16 @@ module Woods
12
15
  # of the index output directory. Each file is named by git SHA and contains
13
16
  # manifest metadata plus per-unit content hashes.
14
17
  #
18
+ # Retention matches the payload store (audit P8): capture prunes the
19
+ # oldest snapshots beyond the configured count, keyed off the same
20
+ # WOODS_PAYLOAD_RETENTION env var. Old histories answering `diff` and
21
+ # `unit_history` beyond the retention window degrade to empty.
22
+ #
15
23
  # Implements the same public interface as SnapshotStore so the MCP server
16
24
  # tools work identically.
25
+ # Malformed retained snapshots are warned about and treated as absent:
26
+ # +find+ returns nil, +diff+ returns an empty result, and history/list scans
27
+ # omit the corrupt file.
17
28
  #
18
29
  # @example
19
30
  # store = JsonSnapshotStore.new(dir: '/app/tmp/woods')
@@ -22,8 +33,9 @@ module Woods
22
33
  # store.diff("abc123", "def456") # => { added: [...], modified: [...], deleted: [...] }
23
34
  #
24
35
  class JsonSnapshotStore # rubocop:disable Metrics/ClassLength
25
- def initialize(dir:)
36
+ def initialize(dir:, retention: nil)
26
37
  @dir = File.join(dir, 'snapshots')
38
+ @retention = resolve_retention(retention)
27
39
  FileUtils.mkdir_p(@dir)
28
40
  end
29
41
 
@@ -34,7 +46,11 @@ module Woods
34
46
  # reach snapshot_path, which raises ArgumentError on a non-hex SHA.
35
47
  return nil unless git_sha.is_a?(String) && git_sha.match?(/\A[0-9a-f]+\z/i)
36
48
 
37
- previous = find_latest
49
+ # Exclude the SHA being captured, mirroring the SQLite store: a
50
+ # re-capture at an unchanged HEAD must diff against the latest
51
+ # distinct snapshot, not whatever this SHA's own file used to hold
52
+ # (L20).
53
+ previous = find_latest(exclude_sha: git_sha)
38
54
  snapshot = build_snapshot(manifest, git_sha, unit_hashes)
39
55
 
40
56
  if previous
@@ -45,6 +61,7 @@ module Woods
45
61
  end
46
62
 
47
63
  write_snapshot(git_sha, snapshot)
64
+ prune_snapshots(protect: git_sha)
48
65
  snapshot.except(:units)
49
66
  end
50
67
 
@@ -58,7 +75,9 @@ module Woods
58
75
  path = snapshot_path(git_sha)
59
76
  return nil unless File.exist?(path)
60
77
 
61
- data = JSON.parse(File.read(path))
78
+ data = read_snapshot(path)
79
+ return nil unless data
80
+
62
81
  symbolize_snapshot(data).except(:units)
63
82
  end
64
83
 
@@ -77,26 +96,69 @@ module Woods
77
96
  .reverse
78
97
  .first(limit)
79
98
 
80
- entries = snapshots.filter_map do |snap|
81
- unit = snap[:units]&.[](identifier)
82
- next unless unit
83
-
84
- {
85
- git_sha: snap[:git_sha],
86
- extracted_at: snap[:extracted_at],
87
- git_branch: snap[:git_branch],
88
- unit_type: unit[:unit_type],
89
- source_hash: unit[:source_hash],
90
- metadata_hash: unit[:metadata_hash],
91
- dependencies_hash: unit[:dependencies_hash]
92
- }
93
- end
99
+ entries = snapshots.flat_map do |snap|
100
+ snap[:units].values.select { |unit| unit[:identifier] == identifier }.map do |unit|
101
+ unit.except(:identifier).merge(
102
+ git_sha: snap[:git_sha], extracted_at: snap[:extracted_at], git_branch: snap[:git_branch]
103
+ )
104
+ end
105
+ end.first(limit)
94
106
 
95
107
  mark_changed_entries(entries)
96
108
  end
97
109
 
98
110
  private
99
111
 
112
+ # Resolve the retention count: an explicit positive value wins, then
113
+ # the WOODS_PAYLOAD_RETENTION env var the payload store honors, then
114
+ # the payload store's default.
115
+ #
116
+ # @param retention [Integer, nil]
117
+ # @return [Integer]
118
+ def resolve_retention(retention)
119
+ return retention if retention.is_a?(Integer) && retention.positive?
120
+
121
+ value = ENV.fetch('WOODS_PAYLOAD_RETENTION', nil).to_i
122
+ value.positive? ? value : PayloadStore::DEFAULT_RETENTION
123
+ end
124
+
125
+ # Delete snapshots beyond the retention count, oldest by extracted_at
126
+ # first. +protect+ names the snapshot just captured; it is the newest
127
+ # anyway, and a tie on extracted_at must not delete it.
128
+ #
129
+ # Failures are non-fatal: retention is housekeeping, and a failed
130
+ # prune leaves the store growing as it did before rather than
131
+ # breaking capture.
132
+ #
133
+ # @param protect [String] git SHA of the just-captured snapshot
134
+ # @return [void]
135
+ def prune_snapshots(protect:)
136
+ summaries = load_all_summaries
137
+ overflow = summaries.size - @retention
138
+ return unless overflow.positive?
139
+
140
+ retention_victims(summaries, overflow, protect).each { |sha| FileUtils.rm_f(snapshot_path(sha)) }
141
+ rescue StandardError => e
142
+ warn "[Woods] Snapshot retention failed: #{e.message}"
143
+ end
144
+
145
+ # Oldest `overflow` snapshots by extracted_at, never the protected one.
146
+ # The protected SHA is rejected before sorting and slicing: it is
147
+ # captured last, so its extracted_at can tie or precede older entries,
148
+ # and letting it occupy the victim slice would leave the store one file
149
+ # over its bound with no replacement victim selected.
150
+ #
151
+ # @param summaries [Array<Hash>]
152
+ # @param overflow [Integer]
153
+ # @param protect [String] git SHA of the just-captured snapshot
154
+ # @return [Array<String>]
155
+ def retention_victims(summaries, overflow, protect)
156
+ summaries.reject { |summary| summary[:git_sha] == protect }
157
+ .sort_by { |summary| summary[:extracted_at] || '' }
158
+ .first(overflow)
159
+ .map { |summary| summary[:git_sha] }
160
+ end
161
+
100
162
  def mget(hash, key)
101
163
  hash[key] || hash[key.to_sym]
102
164
  end
@@ -124,7 +186,8 @@ module Woods
124
186
  id = mget(uh, 'identifier')
125
187
  next if id.nil?
126
188
 
127
- [id, {
189
+ [JSON.generate([id, mget(uh, 'type').to_s]), {
190
+ identifier: id,
128
191
  unit_type: mget(uh, 'type').to_s,
129
192
  source_hash: mget(uh, 'source_hash'),
130
193
  metadata_hash: mget(uh, 'metadata_hash'),
@@ -144,27 +207,26 @@ module Woods
144
207
  if data_a[:source_hash] != data_b[:source_hash] ||
145
208
  data_a[:metadata_hash] != data_b[:metadata_hash] ||
146
209
  data_a[:dependencies_hash] != data_b[:dependencies_hash]
147
- modified << { identifier: identifier, unit_type: data_b[:unit_type] }
210
+ modified << { identifier: data_b[:identifier], unit_type: data_b[:unit_type] }
148
211
  end
149
212
  else
150
- added << { identifier: identifier, unit_type: data_b[:unit_type] }
213
+ added << { identifier: data_b[:identifier], unit_type: data_b[:unit_type] }
151
214
  end
152
215
  end
153
216
 
154
217
  units_a.each do |identifier, data_a|
155
- deleted << { identifier: identifier, unit_type: data_a[:unit_type] } unless units_b.key?(identifier)
218
+ deleted << { identifier: data_a[:identifier], unit_type: data_a[:unit_type] } unless units_b.key?(identifier)
156
219
  end
157
220
 
158
221
  { added: added, modified: modified, deleted: deleted }
159
222
  end
160
223
 
161
224
  def mark_changed_entries(entries)
162
- entries.each_with_index do |entry, i|
163
- entry[:changed] = if i == entries.size - 1
164
- true
165
- else
166
- entry[:source_hash] != entries[i + 1][:source_hash]
167
- end
225
+ entries.group_by { |entry| entry[:unit_type] }.each_value do |versions|
226
+ versions.each_with_index do |entry, index|
227
+ older = versions[index + 1]
228
+ entry[:changed] = older.nil? || entry[:source_hash] != older[:source_hash]
229
+ end
168
230
  end
169
231
  entries
170
232
  end
@@ -176,37 +238,43 @@ module Woods
176
238
  end
177
239
 
178
240
  def write_snapshot(git_sha, data)
179
- File.write(snapshot_path(git_sha), JSON.pretty_generate(data))
241
+ AtomicFile.write(snapshot_path(git_sha), JSON.pretty_generate(data))
180
242
  end
181
243
 
182
244
  def load_snapshot_with_units(git_sha)
183
245
  path = snapshot_path(git_sha)
184
246
  return nil unless File.exist?(path)
185
247
 
186
- symbolize_snapshot(JSON.parse(File.read(path)))
248
+ data = read_snapshot(path)
249
+ data && symbolize_snapshot(data)
187
250
  end
188
251
 
189
252
  def load_all_summaries
190
253
  Dir.glob(File.join(@dir, '*.json')).filter_map do |path|
191
- data = JSON.parse(File.read(path))
192
- symbolize_snapshot(data).except(:units)
193
- rescue JSON::ParserError => e
194
- warn "[Woods] Skipping corrupt snapshot #{File.basename(path)}: #{e.message}"
195
- nil
254
+ data = read_snapshot(path)
255
+ symbolize_snapshot(data).except(:units) if data
196
256
  end
197
257
  end
198
258
 
199
259
  def load_all_with_units
200
260
  Dir.glob(File.join(@dir, '*.json')).filter_map do |path|
201
- symbolize_snapshot(JSON.parse(File.read(path)))
202
- rescue JSON::ParserError => e
203
- warn "[Woods] Skipping corrupt snapshot #{File.basename(path)}: #{e.message}"
204
- nil
261
+ data = read_snapshot(path)
262
+ symbolize_snapshot(data) if data
205
263
  end
206
264
  end
207
265
 
208
- def find_latest
266
+ def read_snapshot(path)
267
+ JSON.parse(AtomicFile.read(path))
268
+ rescue JSON::ParserError => e
269
+ warn "[Woods] Skipping corrupt snapshot #{File.basename(path)}: #{e.message}"
270
+ nil
271
+ end
272
+
273
+ # @param exclude_sha [String, nil] SHA to leave out of the result
274
+ # @return [Hash, nil]
275
+ def find_latest(exclude_sha: nil)
209
276
  snapshots = load_all_summaries
277
+ snapshots.reject! { |s| s[:git_sha] == exclude_sha } if exclude_sha
210
278
  return nil if snapshots.empty?
211
279
 
212
280
  latest = snapshots.max_by { |s| s[:extracted_at] || '' }
@@ -234,13 +302,15 @@ module Woods
234
302
  def symbolize_units(units)
235
303
  return {} unless units
236
304
 
237
- units.transform_values do |v|
238
- {
305
+ units.to_h do |key, v|
306
+ identifier = v['identifier'] || key
307
+ [JSON.generate([identifier, v['unit_type']]), {
308
+ identifier: identifier,
239
309
  unit_type: v['unit_type'],
240
310
  source_hash: v['source_hash'],
241
311
  metadata_hash: v['metadata_hash'],
242
312
  dependencies_hash: v['dependencies_hash']
243
- }
313
+ }]
244
314
  end
245
315
  end
246
316
  end
@@ -29,6 +29,7 @@ module Woods
29
29
  # construct the store with a bare mock.
30
30
  def initialize(connection:, validate_schema: true)
31
31
  @db = connection
32
+ @db.busy_timeout = 5_000 if @db.respond_to?(:busy_timeout=)
32
33
  validate_schema! if validate_schema
33
34
  end
34
35
 
@@ -69,8 +70,10 @@ module Woods
69
70
  def schema_error_message(detail)
70
71
  'SnapshotStore requires the `woods_snapshots` and ' \
71
72
  '`woods_snapshot_units` tables (migrations 004 + 005 under ' \
72
- '`lib/woods/db/migrations/`). Run `rake woods:migrate` on the ' \
73
- "metadata DB and retry. Underlying error: #{detail}"
73
+ '`lib/woods/db/migrations/`). These run automatically on woods-mcp ' \
74
+ 'boot (Bootstrapper.build_snapshot_store auto-migrates), or run them ' \
75
+ 'directly with `Woods::Db::Migrator.new(connection: db).migrate!`. ' \
76
+ "Underlying error: #{detail}"
74
77
  end
75
78
 
76
79
  public
@@ -80,6 +83,13 @@ module Woods
80
83
  # Stores the manifest metadata and per-unit content hashes.
81
84
  # Computes diff stats vs. the most recent previous snapshot.
82
85
  #
86
+ # Re-capturing at an unchanged HEAD updates the snapshot row **in
87
+ # place** — the row id is stable across captures (#206), so the
88
+ # per-unit DELETE below genuinely clears the previous capture's unit
89
+ # set instead of leaking it. Unit rows orphaned by the pre-#206
90
+ # INSERT OR REPLACE (which minted a fresh id per capture) are swept
91
+ # on every capture so historical leaks self-heal.
92
+ #
83
93
  # @param manifest [Hash] The manifest data (string or symbol keys)
84
94
  # @param unit_hashes [Array<Hash>] Per-unit content hashes
85
95
  # @return [Hash] Snapshot record with diff stats
@@ -90,15 +100,53 @@ module Woods
90
100
  # collide a snapshot row. Mirrors JsonSnapshotStore's path validation.
91
101
  return nil unless git_sha.is_a?(String) && git_sha.match?(/\A[0-9a-f]+\z/i)
92
102
 
93
- previous = find_latest
94
- upsert_snapshot(manifest, git_sha, unit_hashes.size)
103
+ captured = nil
104
+ with_lock_retry do
105
+ @db.transaction(:immediate) do
106
+ # Exclude the SHA being captured: find_latest before the upsert
107
+ # otherwise resolves "previous" to this very row, and
108
+ # update_diff_stats then compares the freshly written unit set
109
+ # against itself — every stat zeroes out silently (L20).
110
+ previous = find_latest(exclude_sha: git_sha)
111
+ upsert_snapshot(manifest, git_sha, unit_hashes.size)
112
+
113
+ snapshot_id = fetch_snapshot_id(git_sha)
114
+ @db.execute('DELETE FROM woods_snapshot_units WHERE snapshot_id = ?', [snapshot_id])
115
+ prune_orphaned_units
116
+ insert_unit_hashes(snapshot_id, unit_hashes)
117
+
118
+ update_diff_stats(snapshot_id, previous)
119
+ captured = find(git_sha)
120
+ end
121
+ end
122
+ captured
123
+ end
95
124
 
96
- snapshot_id = fetch_snapshot_id(git_sha)
97
- @db.execute('DELETE FROM woods_snapshot_units WHERE snapshot_id = ?', [snapshot_id])
98
- insert_unit_hashes(snapshot_id, unit_hashes)
125
+ # Attempts for {#with_lock_retry}, including the first.
126
+ LOCK_ATTEMPTS = 3
127
+
128
+ # SQLite skips the busy handler when it detects a lock-order deadlock
129
+ # (this connection holds SHARED and asks for RESERVED while another
130
+ # already holds RESERVED), so `BEGIN IMMEDIATE` can raise "database is
131
+ # locked" at once despite busy_timeout. Two extractions capturing into
132
+ # one metadata database hit that on CI. A short bounded retry lets the
133
+ # other writer finish; anything else, or a non-SQLite connection,
134
+ # raises straight through.
135
+ def with_lock_retry
136
+ attempt = 0
137
+ begin
138
+ attempt += 1
139
+ yield
140
+ rescue StandardError => e
141
+ raise unless sqlite_busy?(e) && attempt < LOCK_ATTEMPTS
142
+
143
+ sleep(0.05 * attempt)
144
+ retry
145
+ end
146
+ end
99
147
 
100
- update_diff_stats(snapshot_id, previous)
101
- find(git_sha)
148
+ def sqlite_busy?(error)
149
+ defined?(SQLite3::BusyException) && error.is_a?(SQLite3::BusyException)
102
150
  end
103
151
 
104
152
  # List snapshots, optionally filtered by branch.
@@ -193,12 +241,11 @@ module Woods
193
241
  # @param entries [Array<Hash>]
194
242
  # @return [Array<Hash>]
195
243
  def mark_changed_entries(entries)
196
- entries.each_with_index do |entry, i|
197
- entry[:changed] = if i == entries.size - 1
198
- true # Oldest version is always "changed" (first appearance)
199
- else
200
- entry[:source_hash] != entries[i + 1][:source_hash]
201
- end
244
+ entries.group_by { |entry| entry[:unit_type] }.each_value do |versions|
245
+ versions.each_with_index do |entry, index|
246
+ older = versions[index + 1]
247
+ entry[:changed] = older.nil? || entry[:source_hash] != older[:source_hash]
248
+ end
202
249
  end
203
250
  entries
204
251
  end
@@ -212,7 +259,37 @@ module Woods
212
259
  hash[key] || hash[key.to_sym]
213
260
  end
214
261
 
215
- # Insert or replace the snapshot row from manifest data.
262
+ # Upsert SQL for the snapshot row, keyed on UNIQUE(git_sha).
263
+ #
264
+ # Deliberately `ON CONFLICT ... DO UPDATE` (SQLite >= 3.24 — the same
265
+ # syntax `Storage::MetadataStore` already relies on), **not**
266
+ # `INSERT OR REPLACE`: REPLACE resolves the conflict by deleting the
267
+ # old row and inserting a fresh one with a new AUTOINCREMENT id, so
268
+ # {#capture}'s subsequent `DELETE ... WHERE snapshot_id = ?` matched
269
+ # nothing and every re-extraction at an unchanged HEAD leaked a full
270
+ # unit-hash set — unboundedly, since foreign keys are never enabled on
271
+ # this connection (#206).
272
+ UPSERT_SNAPSHOT_SQL = <<~SQL
273
+ INSERT INTO woods_snapshots
274
+ (git_sha, git_branch, extracted_at, rails_version, ruby_version,
275
+ total_units, unit_counts, gemfile_lock_sha, schema_sha)
276
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
277
+ ON CONFLICT(git_sha) DO UPDATE SET
278
+ git_branch = excluded.git_branch,
279
+ extracted_at = excluded.extracted_at,
280
+ rails_version = excluded.rails_version,
281
+ ruby_version = excluded.ruby_version,
282
+ total_units = excluded.total_units,
283
+ unit_counts = excluded.unit_counts,
284
+ gemfile_lock_sha = excluded.gemfile_lock_sha,
285
+ schema_sha = excluded.schema_sha
286
+ SQL
287
+
288
+ private_constant :UPSERT_SNAPSHOT_SQL
289
+
290
+ # Insert the snapshot row, or update it in place when this git SHA was
291
+ # captured before. The row id is preserved either way (see
292
+ # {UPSERT_SNAPSHOT_SQL}).
216
293
  #
217
294
  # @param manifest [Hash]
218
295
  # @param git_sha [String]
@@ -230,11 +307,22 @@ module Woods
230
307
  mget(manifest, 'gemfile_lock_sha'),
231
308
  mget(manifest, 'schema_sha')
232
309
  ]
233
- @db.execute(<<~SQL, params)
234
- INSERT OR REPLACE INTO woods_snapshots
235
- (git_sha, git_branch, extracted_at, rails_version, ruby_version,
236
- total_units, unit_counts, gemfile_lock_sha, schema_sha)
237
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
310
+ @db.execute(UPSERT_SNAPSHOT_SQL, params)
311
+ end
312
+
313
+ # Delete unit rows whose snapshot row no longer exists.
314
+ #
315
+ # Before #206, every re-capture of an unchanged SHA stranded the
316
+ # previous capture's unit rows under a snapshot id REPLACE had deleted
317
+ # (foreign keys are never enabled on this connection, so nothing else
318
+ # cleans them up). One cheap sweep per capture lets databases that
319
+ # accumulated those leaks self-heal.
320
+ #
321
+ # @return [void]
322
+ def prune_orphaned_units
323
+ @db.execute(<<~SQL)
324
+ DELETE FROM woods_snapshot_units
325
+ WHERE snapshot_id NOT IN (SELECT id FROM woods_snapshots)
238
326
  SQL
239
327
  end
240
328
 
@@ -251,11 +339,23 @@ module Woods
251
339
  )
252
340
  end
253
341
 
254
- # Find the most recent snapshot.
342
+ # Find the most recent snapshot, optionally excluding one SHA.
255
343
  #
344
+ # {#capture} always excludes the SHA it is about to upsert — see the
345
+ # note at the call site (L20).
346
+ #
347
+ # @param exclude_sha [String, nil] SHA to leave out of the result
256
348
  # @return [Hash, nil]
257
- def find_latest
258
- row = @db.get_first_row('SELECT * FROM woods_snapshots ORDER BY extracted_at DESC LIMIT 1')
349
+ def find_latest(exclude_sha: nil)
350
+ row =
351
+ if exclude_sha
352
+ @db.get_first_row(
353
+ 'SELECT * FROM woods_snapshots WHERE git_sha != ? ORDER BY extracted_at DESC LIMIT 1',
354
+ [exclude_sha]
355
+ )
356
+ else
357
+ @db.get_first_row('SELECT * FROM woods_snapshots ORDER BY extracted_at DESC LIMIT 1')
358
+ end
259
359
  return nil unless row
260
360
 
261
361
  row_to_hash(row)
@@ -281,20 +381,16 @@ module Woods
281
381
  VALUES (?, ?, ?, ?, ?, ?)
282
382
  SQL
283
383
 
284
- # Wrap in a transaction to batch all inserts into a single commit,
285
- # reducing per-row fsync overhead from O(n) to O(1).
286
- @db.transaction do
287
- unit_hashes.each do |uh|
288
- params = [
289
- snapshot_id,
290
- mget(uh, 'identifier'),
291
- mget(uh, 'type').to_s,
292
- mget(uh, 'source_hash'),
293
- mget(uh, 'metadata_hash'),
294
- mget(uh, 'dependencies_hash')
295
- ]
296
- @db.execute(sql, params)
297
- end
384
+ unit_hashes.each do |uh|
385
+ params = [
386
+ snapshot_id,
387
+ mget(uh, 'identifier'),
388
+ mget(uh, 'type').to_s,
389
+ mget(uh, 'source_hash'),
390
+ mget(uh, 'metadata_hash'),
391
+ mget(uh, 'dependencies_hash')
392
+ ]
393
+ @db.execute(sql, params)
298
394
  end
299
395
  end
300
396
 
@@ -310,7 +406,8 @@ module Woods
310
406
  rows = @db.execute(sql, [snapshot_id])
311
407
 
312
408
  rows.to_h do |row|
313
- [row['identifier'], {
409
+ [[row['identifier'], row['unit_type']], {
410
+ identifier: row['identifier'],
314
411
  unit_type: row['unit_type'],
315
412
  source_hash: row['source_hash'],
316
413
  metadata_hash: row['metadata_hash'],
@@ -337,16 +434,16 @@ module Woods
337
434
  if data_a[:source_hash] != data_b[:source_hash] ||
338
435
  data_a[:metadata_hash] != data_b[:metadata_hash] ||
339
436
  data_a[:dependencies_hash] != data_b[:dependencies_hash]
340
- modified << { identifier: identifier, unit_type: data_b[:unit_type] }
437
+ modified << { identifier: data_b[:identifier], unit_type: data_b[:unit_type] }
341
438
  end
342
439
  else
343
- added << { identifier: identifier, unit_type: data_b[:unit_type] }
440
+ added << { identifier: data_b[:identifier], unit_type: data_b[:unit_type] }
344
441
  end
345
442
  end
346
443
 
347
444
  # Units in A but not B → deleted
348
445
  units_a.each do |identifier, data_a|
349
- deleted << { identifier: identifier, unit_type: data_a[:unit_type] } unless units_b.key?(identifier)
446
+ deleted << { identifier: data_a[:identifier], unit_type: data_a[:unit_type] } unless units_b.key?(identifier)
350
447
  end
351
448
 
352
449
  { added: added, modified: modified, deleted: deleted }