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
@@ -9,6 +9,7 @@ require_relative 'provider_probe'
9
9
  require_relative '../index_artifact'
10
10
  require_relative '../builder'
11
11
  require_relative '../resolved_config'
12
+ require_relative '../generation'
12
13
  require_relative '../storage/snapshotter'
13
14
  require_relative '../storage/inapplicable_backend'
14
15
 
@@ -33,7 +34,7 @@ module Woods
33
34
  exit 1
34
35
  end
35
36
 
36
- unless File.exist?(File.join(dir, 'manifest.json'))
37
+ unless manifest_present?(dir)
37
38
  warn "Error: No manifest.json found in: #{dir}"
38
39
  warn 'Run `bundle exec rake woods:extract` in your Rails app first.'
39
40
  exit 1
@@ -42,6 +43,26 @@ module Woods
42
43
  dir
43
44
  end
44
45
 
46
+ # Is there a manifest this index would resolve one from?
47
+ #
48
+ # A flat index (pre-payload, or never bumped) answers with
49
+ # +manifest.json+ directly under +dir+. A payload-born index (#164
50
+ # payloads) has none there — every artifact lives under the directory
51
+ # +generation.json+'s +payload+ pointer names — so the pointer is
52
+ # followed before concluding there is no index at all. Mirrors
53
+ # {Woods::MCP::IndexReader#manifest_present?} exactly; keep the two in
54
+ # agreement.
55
+ #
56
+ # @param dir [String] candidate index directory
57
+ # @return [Boolean]
58
+ def self.manifest_present?(dir)
59
+ return true if File.exist?(File.join(dir, 'manifest.json'))
60
+
61
+ generation = Woods::Generation.new(output_dir: dir)
62
+ generation.payload_dir(generation.current).join('manifest.json').file?
63
+ end
64
+ private_class_method :manifest_present?
65
+
45
66
  # Build a snapshot store for temporal tracking.
46
67
  #
47
68
  # Auto-enables when a SQLite database already exists in the index directory,
@@ -111,6 +132,10 @@ module Woods
111
132
  state.mark(:hydrating)
112
133
 
113
134
  artifact = build_artifact(index_dir)
135
+ if static_source_map_without_embeddings?(artifact)
136
+ state.mark(:degraded, reason: Woods::Error.new('static source map has no embedding artifact'))
137
+ return [nil, state]
138
+ end
114
139
  config, _source = ConfigResolver.resolve(Woods.configuration,
115
140
  artifact: artifact,
116
141
  ollama_probe: method(:ollama_reachable?))
@@ -128,104 +153,476 @@ module Woods
128
153
  # artifact to validate against.
129
154
  resolved = build_resolved_config(config)
130
155
  state.resolved_config = resolved
131
- retriever = build_retriever_from_config(config, resolved, artifact)
156
+ retriever = build_retriever_from_config(config, resolved, artifact, state)
132
157
  probe_and_mark_state(config, state)
158
+ derive_state_from_store_health(state)
133
159
  warn "[woods-mcp] semantic search: #{state.status} (#{config.embedding_provider})"
134
160
 
135
161
  [retriever, state]
136
162
  end
137
163
 
138
- # Backwards-compatible wrapper — existing callers (exe/woods-mcp and
139
- # exe/woods-mcp-http) just want the retriever. They rescue typed
140
- # BootstrapError at their own top level; we do not catch here.
141
- def self.build_retriever_compat(index_dir: nil)
142
- retriever, _state = build_retriever(index_dir: index_dir)
143
- retriever
164
+ def self.static_source_map_without_embeddings?(artifact)
165
+ return false unless artifact
166
+
167
+ generation = Woods::Generation.new(output_dir: artifact.output_dir)
168
+ manifest_path = generation.payload_dir(generation.current).join('manifest.json')
169
+ return false unless manifest_path.file?
170
+
171
+ provenance = JSON.parse(Woods::AtomicFile.read(manifest_path))['provenance'] || {}
172
+ provenance['mode'] == 'woods_static_ruby_source' && provenance['embeddings'] == 'absent'
173
+ rescue JSON::ParserError, SystemCallError
174
+ false
144
175
  end
176
+ private_class_method :static_source_map_without_embeddings?
145
177
 
146
178
  # Refresh a live retriever's in-memory stores from the latest dumps on
147
179
  # disk. Used by the MCP +reload+ tool so agents can pick up a fresh embed
148
- # run without restarting the process. The retriever instance is preserved
149
- # (tool closures kept their reference) — only the stores are mutated.
180
+ # run without restarting the process.
181
+ #
182
+ # The transaction is build-then-swap (M7) — the old +clear!+ +
183
+ # +bulk_load+ on the LIVE stores left an empty or half-loaded window a
184
+ # concurrent reader could observe:
150
185
  #
151
- # No-op when:
186
+ # 1. Capture ONCE, before any candidate work: the served generation
187
+ # marker AND the promoted dump identity (+dumps/latest+). Every
188
+ # candidate then hydrates EXCLUSIVELY from those captured
189
+ # locations — config from the captured dump's embedded snapshot,
190
+ # vector/metadata from the captured dump directory, the graph from
191
+ # the captured marker's payload — never from whatever is on disk
192
+ # mid-build.
193
+ # 2. If ANY candidate fails: the reader is not reloaded and no store
194
+ # is swapped — the previous fully aligned generation stays served,
195
+ # the old retriever keeps answering, and a DISTINCT reload-phase
196
+ # degraded condition is recorded on +state+ (never the boot
197
+ # degraded state, which describes the old stores). Raised as
198
+ # {Woods::MCP::ReloadDegraded}. The same fail-closed rule applies
199
+ # before any candidate work when the captured dump's embedded
200
+ # config names store types the refreshable live target cannot
201
+ # refresh (a re-embed switched stores): that divergence must never
202
+ # surface as an empty success (M2).
203
+ # 3. Commit: acquire the SAME on-disk PipelineLock every writer uses
204
+ # (bounded poll; a reload must not queue behind a long extraction),
205
+ # THEN the reader's exclusive generation lock, THEN recheck BOTH
206
+ # identities. A generation movement raises
207
+ # {Woods::MCP::ReloadGenerationMoved}; a promoted-dump movement
208
+ # raises {Woods::MCP::ReloadDumpMoved} (an embed promotes without
209
+ # bumping the generation file, so the dump identity is rechecked
210
+ # separately). The writer lock is held through reader alignment and
211
+ # the single Pipeline assignment — no publication can interleave
212
+ # between recheck and swap — and released AFTER the reader lock.
213
+ # 4. A successful reload clears the reload-failure condition.
214
+ #
215
+ # Deterministic-interleaving hooks (M7 specs): +hooks+ carries optional
216
+ # callables — +after_vector_candidate:+ (post-vector, pre-metadata
217
+ # hydration), +after_pipeline_lock:+ (writer lock held, reader lock not
218
+ # yet taken), +before_swap:+ (both rechecks passed, Pipeline assignment
219
+ # imminent), +after_swap:+ (bundle swapped). Production passes nil.
220
+ #
221
+ # A zero-count no-op when:
152
222
  # - +retriever+ is nil (no embedding provider configured)
153
- # - stores are durable (pgvector / Qdrant auto-refresh externally)
223
+ # - the live stores are durable (pgvector / Qdrant auto-refresh
224
+ # externally) — a partial all-or-nothing swap would mix backends
154
225
  # - +woods.json+ is absent (Shape-1 deployments don't use Snapshotter)
226
+ # - no dump has ever been promoted (extraction ran, +woods:embed+
227
+ # never did) — boot hydrates that shape with +load_or_empty+, so
228
+ # reload must not fail closed over it (MCP-1)
229
+ #
230
+ # Every one of those returns still refreshes +reader+ first (MCP-2).
231
+ # There is no store work to do, but the JSON index may still have moved,
232
+ # and on a flat (pre-2.0) index the reader never self-refreshes — reload
233
+ # is its only freshness path, so a store no-op must not become an index
234
+ # no-op reported as +reloaded: true+ over the retired generation.
155
235
  #
156
- # @param retriever [Woods::Retriever, nil]
236
+ # @param retriever [Woods::Retriever, Cache::CachedRetriever, nil]
157
237
  # @param index_dir [String, Pathname]
238
+ # @param reader [Woods::MCP::IndexReader, nil] when given, the commit
239
+ # phase runs under the reader's exclusive generation lock and the
240
+ # reader's caches are reloaded alongside the swap
241
+ # @param state [Woods::MCP::BootstrapState, nil] records the reload
242
+ # -phase degraded condition and its recovery
243
+ # @param hooks [Hash, nil] optional deterministic-interleaving hooks
158
244
  # @return [Hash] Stats — +{ vectors:, metadata:, graph: }+ record counts
159
- # @raise [Woods::MCP::BootstrapError] surfaced from ConfigResolver / Snapshotter
160
- def self.reload_stores!(retriever, index_dir:)
161
- return { vectors: 0, metadata: 0, graph: 0 } unless retriever
245
+ # @raise [Woods::MCP::ReloadDegraded] the transaction aborted; nothing
246
+ # was swapped and the generation named by the error is still served
247
+ def self.reload_stores!(retriever, index_dir:, reader: nil, state: nil, hooks: nil)
248
+ zero_counts = { vectors: 0, metadata: 0, graph: 0 }
249
+ return refresh_reader_only(reader, zero_counts) unless retriever
250
+
251
+ target = swap_target(retriever)
252
+ return refresh_reader_only(reader, zero_counts) unless target
162
253
 
163
254
  artifact = build_artifact(index_dir)
164
- config, _source = ConfigResolver.resolve(Woods.configuration,
165
- artifact: artifact,
166
- ollama_probe: method(:ollama_reachable?))
167
- resolved = build_resolved_config(config)
255
+ return refresh_reader_only(reader, zero_counts) unless artifact
168
256
 
169
- vectors_count = refill_in_memory_vector_store(retriever, config, resolved, artifact)
170
- metadata_count = refill_in_memory_metadata_store(retriever, config, resolved, artifact)
171
- graph_count = refill_in_memory_graph_store(retriever, config, artifact)
257
+ generation = Woods::Generation.new(output_dir: artifact.output_dir)
172
258
 
173
- # Context-cache entries from the previous embed run no longer agree
174
- # with the refreshed stores. Drop them so the next codebase_retrieve
175
- # call goes through the full pipeline with the new data. Embedding
176
- # caches (query → vector) survive — that mapping is deterministic
177
- # for a given provider+model.
178
- retriever.invalidate_context_cache! if retriever.respond_to?(:invalidate_context_cache!)
259
+ # CAPTURE ONCE — the identities every candidate and the commit recheck
260
+ # are anchored to. Nothing downstream re-reads a live pointer until
261
+ # the commit recheck itself.
262
+ served = generation.current
263
+ captured_dump = artifact.latest_dump_path
179
264
 
180
- { vectors: vectors_count, metadata: metadata_count, graph: graph_count }
265
+ # Nothing has ever been promoted: extraction ran, `woods:embed` did
266
+ # not. Boot hydrates this with `load_or_empty` and serves empty stores
267
+ # happily, so reload has to agree — building candidates with
268
+ # `required: true` against a nil dump raises MissingArtifact, degrades
269
+ # the transaction, and sticks a reload_failure on `state` that only a
270
+ # SUCCESSFUL reload clears, which is impossible until an embed runs
271
+ # (MCP-1). A dump that IS promoted but incomplete stays fail-closed.
272
+ return refresh_reader_only(reader, zero_counts) if captured_dump.nil?
273
+
274
+ begin
275
+ config, _source = ConfigResolver.resolve(
276
+ Woods.configuration,
277
+ artifact: artifact,
278
+ ollama_probe: method(:ollama_reachable?),
279
+ stored_config: captured_stored_config(artifact, captured_dump)
280
+ )
281
+ rescue StandardError => e
282
+ raise ReloadDegraded.new(
283
+ 'reload could not resolve the index configuration; the previous generation is still ' \
284
+ "being served: #{e.class}: #{e.message}",
285
+ generation: served.number, stores: %w[vector metadata graph], error: e
286
+ )
287
+ end
288
+ resolved = build_resolved_config(config)
289
+
290
+ return refresh_reader_only(reader, zero_counts) unless refreshable_stores?(target)
291
+
292
+ # A captured dump whose embedded config names store types the
293
+ # refreshable live target cannot refresh cannot be turned into
294
+ # candidates at all — fail closed with the reload-phase degraded
295
+ # condition instead of an empty success (M2).
296
+ assert_dump_stores_refreshable!(target, config, served)
297
+
298
+ # Phase 1: candidates off-side, exclusively from the captured locations.
299
+ candidates = build_reload_candidates(config, resolved, artifact, served, captured_dump, hooks)
300
+ return refresh_reader_only(reader, zero_counts) unless candidates
301
+
302
+ # Phase 2: writer lock, reader lock, recheck both identities, swap.
303
+ commit_reload!(target, candidates, artifact: artifact, generation: generation,
304
+ served: served, captured_dump: captured_dump, reader: reader,
305
+ retriever: retriever, state: state, hooks: hooks)
306
+ rescue Woods::MCP::ReloadDegraded => e
307
+ state&.record_reload_failure(generation: e.generation, stores: e.stores,
308
+ reason: "#{e.class}: #{e.message}")
309
+ raise
181
310
  end
182
311
 
183
- # Retriever (and CachedRetriever) expose public +vector_store+ /
184
- # +metadata_store+ / +graph_store+ readers so this helper never pokes
185
- # private state. Durable backends don't implement +clear!+/+bulk_load+
186
- # — they return 0 silently because they're already refreshed externally.
187
- def self.refill_in_memory_vector_store(retriever, config, resolved, artifact)
188
- vs = retriever.respond_to?(:vector_store) ? retriever.vector_store : nil
189
- return 0 unless vs.respond_to?(:clear!) && vs.respond_to?(:bulk_load)
312
+ # Refresh the reader's cached index state and answer the zero-count
313
+ # no-op (MCP-2).
314
+ #
315
+ # A store no-op is not an index no-op: the caller invoked `reload`
316
+ # because something on disk moved, and on a flat (pre-2.0) index
317
+ # {IndexReader} never self-refreshes, so skipping this reports
318
+ # +reloaded: true+ over the retired generation. Runs under the reader's
319
+ # exclusive generation lock, the same gate the swap path uses, so pinned
320
+ # readers drain before the caches are cleared.
321
+ #
322
+ # @param reader [Woods::MCP::IndexReader, nil]
323
+ # @param zero_counts [Hash]
324
+ # @return [Hash] +zero_counts+, unchanged
325
+ def self.refresh_reader_only(reader, zero_counts)
326
+ reader&.with_exclusive_reload { nil }
327
+ zero_counts
328
+ end
329
+ private_class_method :refresh_reader_only
330
+
331
+ # The stored config snapshot anchored at the CAPTURED dump (M7): the
332
+ # embedded +woods.json+ inside that dump directory, falling back to the
333
+ # root file for dumps that predate embedded configs — the same
334
+ # preference order as {IndexArtifact#read_config}, but pinned to the
335
+ # captured identity instead of re-reading the +latest+ pointer. Nil when
336
+ # neither exists, which resolves the same way a missing snapshot does.
337
+ def self.captured_stored_config(artifact, captured_dump)
338
+ return nil unless captured_dump
339
+
340
+ embedded = artifact.dump_config_path(captured_dump)
341
+ if embedded.exist?
342
+ ResolvedConfig.from_hash(JSON.parse(embedded.read(encoding: Encoding::UTF_8)))
343
+ elsif artifact.config_path.exist?
344
+ ResolvedConfig.from_hash(JSON.parse(artifact.config_path.read(encoding: Encoding::UTF_8)))
345
+ end
346
+ end
347
+ private_class_method :captured_stored_config
348
+
349
+ # Reach the swappable retriever inside a (possibly cache-wrapped)
350
+ # retriever. {Cache::CachedRetriever} keeps the real one in +@retriever+
351
+ # and nothing else wraps today; the guarded ivar probe degrades to nil
352
+ # on an unknown shape rather than raising mid-reload. Same rationale as
353
+ # the retired +extract_ranker+ helper this replaces.
354
+ def self.swap_target(retriever)
355
+ return retriever if retriever.respond_to?(:swap_stores!)
356
+
357
+ inner = (retriever.instance_variable_get(:@retriever) if retriever.instance_variable_defined?(:@retriever))
358
+ inner if inner.respond_to?(:swap_stores!)
359
+ end
360
+ private_class_method :swap_target
361
+
362
+ # Can this retriever's vector AND metadata stores both be refreshed
363
+ # here? In-memory stores expose +clear!+/+bulk_load+; durable backends
364
+ # (pgvector, Qdrant) don't implement +clear!+ — they're refreshed
365
+ # externally. The transaction is all-or-nothing, so a shape that can't
366
+ # refresh both takes the no-op rather than a partial swap.
367
+ def self.refreshable_stores?(target)
368
+ vs = target.vector_store
369
+ ms = target.metadata_store
370
+ vs.respond_to?(:clear!) && vs.respond_to?(:bulk_load) &&
371
+ ms.respond_to?(:clear!) && ms.respond_to?(:bulk_load)
372
+ end
373
+ private_class_method :refreshable_stores?
374
+
375
+ # The captured dump's embedded config and the refreshable live target
376
+ # must agree on store types (M2). Each is valid alone: the live target
377
+ # is refreshable (in-memory), and the dump is complete and valid. But
378
+ # when a re-embed ran with a different store configuration and promoted
379
+ # over the dump this process hydrated from, the reload-time resolver
380
+ # adopts the dump's store types, every candidate builder returns nil,
381
+ # and the transaction used to answer +reloaded: true+ with zero counts
382
+ # while NOTHING was swapped and no degraded condition was recorded —
383
+ # each component fine, the combination a misaligned-state success.
384
+ # Zero counts with a genuine empty dump stays legitimate; store-type
385
+ # divergence is a degraded reload: the reload-phase condition names
386
+ # both sides of the mismatch and the honest state, nothing swaps, and
387
+ # the previous generation keeps being served.
388
+ def self.assert_dump_stores_refreshable!(target, config, served)
389
+ divergent = {
390
+ vector: config.vector_store,
391
+ metadata: config.metadata_store
392
+ }.reject { |_, dump_type| dump_type == :in_memory }
393
+ return if divergent.empty?
394
+
395
+ detail = divergent.map do |component, dump_type|
396
+ live_store = component == :vector ? target.vector_store : target.metadata_store
397
+ "the promoted dump declares #{component}_store=#{dump_type.inspect} but the live retriever " \
398
+ "serves a #{live_store.class} #{component} store"
399
+ end.join('; ')
400
+
401
+ raise ReloadDegraded.new(
402
+ "reload aborted: #{detail}; nothing was swapped and the previous generation is still " \
403
+ 'being served — restart woods-mcp to adopt the dump\'s store configuration, or re-run ' \
404
+ 'woods:embed with the store configuration this server was started with',
405
+ generation: served.number, stores: divergent.keys
406
+ )
407
+ end
408
+ private_class_method :assert_dump_stores_refreshable!
409
+
410
+ # Immutable candidate bundle built off-side. +graph_store+ is nil when
411
+ # the index carries no +dependency_graph.json+ (the live graph is kept).
412
+ ReloadCandidates = Struct.new(:vector_store, :metadata_store, :graph_store,
413
+ :vector_count, :metadata_count, :graph_count,
414
+ keyword_init: true)
415
+ private_constant :ReloadCandidates
416
+
417
+ # Build every candidate store against the CAPTURED identities. Any
418
+ # failure raises {Woods::MCP::ReloadDegraded} naming the failing
419
+ # component; nothing has been swapped at this point.
420
+ #
421
+ # Vector and metadata candidates must both exist (the transaction never
422
+ # half-swaps); a nil graph candidate means "keep the live graph".
423
+ def self.build_reload_candidates(config, resolved, artifact, served, captured_dump, hooks)
424
+ vector = reload_vector_candidate(config, resolved, captured_dump, served)
425
+ return nil unless vector
426
+
427
+ run_hook(hooks, :after_vector_candidate)
428
+
429
+ metadata = reload_metadata_candidate(config, resolved, captured_dump, served)
430
+ return nil unless metadata
431
+
432
+ graph = reload_graph_candidate(config, artifact, served)
433
+
434
+ # Back-fill the candidate vector store's per-entry metadata from the
435
+ # candidate metadata store, OFF-SIDE before the swap. The WVF1 dump
436
+ # persists id + floats only — without this, every type-filtered
437
+ # search returns nothing after a reload (the boot path back-fills;
438
+ # reload must too). Same contract as the boot-path call in
439
+ # {.build_retriever_from_config}.
440
+ populate_vector_metadata(vector, metadata)
441
+
442
+ ReloadCandidates.new(
443
+ vector_store: vector, metadata_store: metadata, graph_store: graph,
444
+ vector_count: vector.count, metadata_count: metadata.count,
445
+ graph_count: graph ? 1 : 0
446
+ )
447
+ end
448
+ private_class_method :build_reload_candidates
190
449
 
191
- fresh = hydrated_vector_store(config, resolved, artifact)
192
- return 0 unless fresh
450
+ def self.reload_vector_candidate(config, resolved, captured_dump, served)
451
+ return nil unless config.vector_store == :in_memory
193
452
 
194
- vs.clear!
195
- vs.bulk_load(fresh.each_entry.map { |id, vec, meta| { id: id, vector: vec, metadata: meta } })
196
- vs.respond_to?(:count) ? vs.count : 0
453
+ Woods::Storage::Snapshotter::Vector.load_dump_dir(
454
+ captured_dump, resolved_config: resolved, required: true
455
+ )
456
+ rescue StandardError => e
457
+ raise ReloadDegraded.new(
458
+ "vector store refresh failed: #{e.class}: #{e.message}",
459
+ generation: served.number, stores: [:vector], error: e
460
+ )
197
461
  end
198
- private_class_method :refill_in_memory_vector_store
462
+ private_class_method :reload_vector_candidate
199
463
 
200
- def self.refill_in_memory_metadata_store(retriever, config, resolved, artifact)
201
- ms = retriever.respond_to?(:metadata_store) ? retriever.metadata_store : nil
202
- return 0 unless ms.respond_to?(:clear!) && ms.respond_to?(:bulk_load)
464
+ def self.reload_metadata_candidate(config, resolved, captured_dump, served)
465
+ return nil unless config.metadata_store == :in_memory
466
+
467
+ Woods::Storage::Snapshotter::Metadata.load_dump_dir(
468
+ captured_dump, resolved_config: resolved, required: true
469
+ )
470
+ rescue StandardError => e
471
+ raise ReloadDegraded.new(
472
+ "metadata store refresh failed: #{e.class}: #{e.message}",
473
+ generation: served.number, stores: [:metadata], error: e
474
+ )
475
+ end
476
+ private_class_method :reload_metadata_candidate
203
477
 
204
- fresh = hydrated_metadata_store(config, resolved, artifact)
205
- return 0 unless fresh
478
+ def self.reload_graph_candidate(config, artifact, served)
479
+ hydrated_graph_store(config, artifact, nil, strict: true, marker: served)
480
+ rescue StandardError => e
481
+ raise ReloadDegraded.new(
482
+ "graph store refresh failed: #{e.class}: #{e.message}",
483
+ generation: served.number, stores: [:graph], error: e
484
+ )
485
+ end
486
+ private_class_method :reload_graph_candidate
487
+
488
+ # Commit phase: acquire the SAME on-disk PipelineLock every extraction /
489
+ # embed writer uses (bounded poll — a reload must not queue behind a
490
+ # long extraction), THEN the reader's exclusive generation lock, THEN
491
+ # recheck BOTH captured identities. The writer lock is held through the
492
+ # reader alignment and the one-assignment Pipeline swap and released
493
+ # AFTER the reader lock, so no writer publication can interleave between
494
+ # recheck and swap.
495
+ def self.commit_reload!(target, candidates, artifact:, generation:, served:,
496
+ captured_dump:, reader:, retriever:, state:, hooks:)
497
+ lock = reload_extraction_lock(artifact.output_dir)
498
+ unless acquire_writer_lock_briefly(lock)
499
+ raise ReloadDegraded.new(
500
+ 'reload could not acquire the extraction writer lock; a writer is actively ' \
501
+ 'publishing. Nothing was swapped and the previous generation is still being ' \
502
+ 'served — invoke reload again once the writer finishes.',
503
+ generation: served.number, stores: %w[vector metadata graph]
504
+ )
505
+ end
506
+
507
+ begin
508
+ run_hook(hooks, :after_pipeline_lock)
509
+
510
+ body = lambda do
511
+ # Recheck BOTH captured identities under the writer lock. An embed
512
+ # promotes without bumping the generation file, so the dump
513
+ # identity needs its own check — the generation marker alone
514
+ # would bless a bundle whose halves hydrate from two dumps.
515
+ current = generation.current
516
+ unless same_generation_marker?(served, current)
517
+ raise ReloadGenerationMoved.new(
518
+ "index generation moved during reload (captured #{served.number}, now #{current.number}); " \
519
+ 'nothing was swapped and the previous generation is still being served — invoke reload again',
520
+ generation: served.number, stores: %w[vector metadata graph]
521
+ )
522
+ end
523
+ current_dump = artifact.latest_dump_path
524
+ unless same_dump_identity?(captured_dump, current_dump)
525
+ raise ReloadDumpMoved.new(
526
+ "promoted dump changed during reload (captured #{captured_dump}, now #{current_dump}); " \
527
+ 'nothing was swapped and the previous generation is still being served — invoke reload again',
528
+ generation: served.number, stores: %w[vector metadata graph]
529
+ )
530
+ end
531
+
532
+ run_hook(hooks, :before_swap)
533
+
534
+ target.swap_stores!(
535
+ vector_store: candidates.vector_store,
536
+ metadata_store: candidates.metadata_store,
537
+ graph_store: candidates.graph_store || target.graph_store
538
+ )
539
+
540
+ # Align the reader caches with the swapped bundle — the retired
541
+ # generation's unit caches, identifier map and graph must not leak
542
+ # into responses describing the new one.
543
+ reader&.reload!
544
+
545
+ # Context-cache entries from the previous embed run no longer agree
546
+ # with the refreshed stores. Drop them so the next codebase_retrieve
547
+ # call goes through the full pipeline with the new data. Embedding
548
+ # caches (query → vector) survive — that mapping is deterministic
549
+ # for a given provider+model. The Ranker needs no memo invalidation:
550
+ # the swapped pipeline carries a FRESH ranker with no memoized
551
+ # PageRank from the retired graph.
552
+ retriever.invalidate_context_cache! if retriever.respond_to?(:invalidate_context_cache!)
553
+
554
+ # Successful recovery clears the reload-phase degraded condition.
555
+ state&.clear_reload_failure!
556
+
557
+ run_hook(hooks, :after_swap)
558
+
559
+ { vectors: candidates.vector_count, metadata: candidates.metadata_count,
560
+ graph: candidates.graph_count }
561
+ end
562
+
563
+ reader ? reader.with_exclusive_generation(&body) : body.call
564
+ ensure
565
+ # Release order: the reader lock (the block above) first, then the
566
+ # writer lock.
567
+ lock.release
568
+ end
569
+ end
570
+ private_class_method :commit_reload!
571
+
572
+ # The same lock every other writer against this index uses — rake
573
+ # extract/incremental/embed and the watch daemon all build it from the
574
+ # daemon's constants (keep in agreement with
575
+ # {Woods::MCP::Server.build_extraction_lock} and the rake helper).
576
+ def self.reload_extraction_lock(output_dir)
577
+ require 'woods/coordination/pipeline_lock'
578
+ require 'woods/watch/daemon'
579
+
580
+ Woods::Coordination::PipelineLock.new(
581
+ lock_dir: output_dir.to_s,
582
+ name: Woods::Watch::Daemon::LOCK_NAME,
583
+ stale_timeout: Woods::Watch::Daemon::LOCK_STALE_TIMEOUT
584
+ )
585
+ end
586
+ private_class_method :reload_extraction_lock
587
+
588
+ # Poll for the writer lock briefly. Monotonic, so a clock adjustment
589
+ # mid-wait cannot stretch or shrink the window. Bounded because a reload
590
+ # must not queue behind a full extraction; a busy writer fails the
591
+ # attempt with a typed retryable error instead.
592
+ WRITER_LOCK_WAIT = 2.0 # seconds — mirrors Server::PIPELINE_LOCK_WAIT
593
+
594
+ def self.acquire_writer_lock_briefly(lock)
595
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WRITER_LOCK_WAIT
596
+ acquired = lock.acquire
597
+ until acquired || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
598
+ sleep 0.1
599
+ acquired = lock.acquire
600
+ end
601
+ acquired
602
+ end
603
+ private_class_method :acquire_writer_lock_briefly
206
604
 
207
- ms.clear!
208
- ms.bulk_load(fresh.each_entry)
209
- ms.respond_to?(:count) ? ms.count : 0
605
+ def self.run_hook(hooks, name)
606
+ hooks&.fetch(name, nil)&.call
210
607
  end
211
- private_class_method :refill_in_memory_metadata_store
608
+ private_class_method :run_hook
212
609
 
213
- # GraphStore::Memory doesn't expose a +clear!+/+bulk_load+ pair today
214
- # — a fresh run hands it an entirely new DependencyGraph from disk.
215
- # Swap the inner graph via +replace_graph+ so SearchExecutor / Ranker /
216
- # MCP tools keep their references to the same wrapper and see the new
217
- # graph (no closure references break).
218
- def self.refill_in_memory_graph_store(retriever, config, artifact)
219
- gs = retriever.respond_to?(:graph_store) ? retriever.graph_store : nil
220
- return 0 unless gs.respond_to?(:replace_graph)
610
+ # Token comparison, number fallback — mirrors IndexReader's
611
+ # same_generation? rule (two collapsed bumps share a number but not a
612
+ # token; pre-token generation files are told apart by number only).
613
+ def self.same_generation_marker?(captured, current)
614
+ return captured.number == current.number if captured.token.nil? || current.token.nil?
221
615
 
222
- fresh = hydrated_graph_store(config, artifact)
223
- return 0 if fresh.nil?
616
+ captured.token == current.token
617
+ end
618
+ private_class_method :same_generation_marker?
224
619
 
225
- gs.replace_graph(fresh.graph)
226
- 1
620
+ # The promoted-dump identity: the resolved +dumps/latest+ path captured
621
+ # before candidate construction, compared against a fresh pointer read.
622
+ def self.same_dump_identity?(captured_dump, current_dump)
623
+ captured_dump.to_s == current_dump.to_s
227
624
  end
228
- private_class_method :refill_in_memory_graph_store
625
+ private_class_method :same_dump_identity?
229
626
 
230
627
  # Check whether Ollama is reachable at the configured base URL.
231
628
  #
@@ -261,10 +658,10 @@ module Woods
261
658
  end
262
659
  private_class_method :build_artifact
263
660
 
264
- def self.build_retriever_from_config(config, resolved, artifact)
265
- vector_store = hydrated_vector_store(config, resolved, artifact)
266
- metadata_store = hydrated_metadata_store(config, resolved, artifact)
267
- graph_store = hydrated_graph_store(config, artifact)
661
+ def self.build_retriever_from_config(config, resolved, artifact, state = nil)
662
+ vector_store = hydrated_vector_store(config, resolved, artifact, state)
663
+ metadata_store = hydrated_metadata_store(config, resolved, artifact, state)
664
+ graph_store = hydrated_graph_store(config, artifact, state)
268
665
 
269
666
  # Cross-populate the vector store's per-entry metadata cache from
270
667
  # the metadata store. The WVF1 binary format stores only id + float
@@ -306,15 +703,32 @@ module Woods
306
703
  # Builder falls back to a fresh empty store — the pre-fix behaviour for
307
704
  # hosts that haven't run an extraction yet.
308
705
  #
706
+ # Resolved through {Woods::Generation#payload_dir}, same as every other
707
+ # payload artifact reader: a payload-born index (#164 payloads) keeps
708
+ # +dependency_graph.json+ only under +payloads/gen-N/+, never at the
709
+ # index root, so reading the root path unconditionally hydrated an
710
+ # empty graph and silently no-op'd PageRank / graph expansion. Resolves
711
+ # to the root itself for a flat (pre-payload) index, so this is a no-op
712
+ # change for every index that predates payloads.
713
+ #
309
714
  # @param config [Woods::Configuration]
310
715
  # @param artifact [Woods::IndexArtifact, nil]
716
+ # @param strict [Boolean] when true (reload candidate path), a soft
717
+ # failure RAISES instead of degrading to an empty store — a reload
718
+ # must never swap a good live store for an empty candidate (M7)
719
+ # @param marker [Woods::Generation::Marker, nil] resolve the payload
720
+ # through THIS generation marker instead of the currently published
721
+ # one (M7: the reload transaction reads the payload of the marker it
722
+ # captured, so a concurrent publish cannot mix a graph from another
723
+ # generation into the candidate bundle)
311
724
  # @return [Woods::Storage::GraphStore::Memory, nil]
312
725
  # @raise [Woods::Storage::InapplicableBackend] if the configured
313
726
  # graph_store reports +durable? => true+
314
- def self.hydrated_graph_store(config, artifact)
727
+ def self.hydrated_graph_store(config, artifact, state = nil, strict: false, marker: nil)
315
728
  return nil unless artifact
316
729
 
317
- graph_json = artifact.output_dir.join('dependency_graph.json')
730
+ generation = Woods::Generation.new(output_dir: artifact.output_dir)
731
+ graph_json = generation.payload_dir(marker || generation.current).join('dependency_graph.json')
318
732
  return nil unless graph_json.exist?
319
733
 
320
734
  require_relative '../dependency_graph'
@@ -328,12 +742,20 @@ module Woods
328
742
  'stores. Populate the durable backend via the extraction write path instead.'
329
743
  end
330
744
 
331
- graph = Woods::DependencyGraph.from_h(JSON.parse(graph_json.read))
745
+ # AtomicFile.read, never a bare Pathname#read (H1): a bare read tags
746
+ # content with Encoding.default_external, and under LANG=C a graph
747
+ # holding any non-ASCII identifier raised
748
+ # Encoding::InvalidByteSequenceError — a locale bug that the M6
749
+ # degraded-state honesty turned into a full retrieval outage.
750
+ graph = Woods::DependencyGraph.from_h(JSON.parse(Woods::AtomicFile.read(graph_json)))
332
751
  Woods::Storage::GraphStore::Memory.new(graph)
333
752
  rescue Woods::Storage::InapplicableBackend
334
753
  raise
335
754
  rescue StandardError => e
755
+ raise if strict
756
+
336
757
  warn "[woods-mcp] graph hydration failed (#{e.class}: #{e.message}); starting with empty store"
758
+ state&.record_hydration_failure(:graph, e)
337
759
  nil
338
760
  end
339
761
  private_class_method :hydrated_graph_store
@@ -351,9 +773,13 @@ module Woods
351
773
  # Back-fill the vector store's per-entry metadata hashes from the
352
774
  # metadata store. Only makes sense when both are in-memory — durable
353
775
  # backends return nil from the hydration helpers and never reach
354
- # this path.
776
+ # this path. Both callers hand over stores they built themselves —
777
+ # boot's freshly hydrated pair and {.build_reload_candidates}'
778
+ # off-side candidates (the M7 transaction retired the old in-place
779
+ # variant that reached a LIVE store) — but the guard below still has
780
+ # to hold: either store can turn out to be a durable adapter.
355
781
  def self.populate_vector_metadata(vector_store, metadata_store)
356
- return unless vector_store.respond_to?(:each_entry) && vector_store.respond_to?(:store)
782
+ return unless implements_own?(vector_store, :each_entry) && vector_store.respond_to?(:store)
357
783
  return unless metadata_store.respond_to?(:find)
358
784
 
359
785
  # Collect (id, vector) pairs in one pass; overwriting via #store
@@ -365,16 +791,76 @@ module Woods
365
791
  meta = metadata_store.find(id.to_s.sub(CHUNK_SUFFIX_PATTERN, ''))
366
792
  next if meta.nil? || (meta.respond_to?(:empty?) && meta.empty?)
367
793
 
368
- vector_store.store(id, vec, meta)
794
+ vector_store.store(id, vec, vector_filter_metadata(meta))
369
795
  end
370
796
  end
371
797
  private_class_method :populate_vector_metadata
372
798
 
799
+ # Does +object+ define +method_name+ itself, rather than merely
800
+ # inheriting {Storage::VectorStore::Interface}'s default
801
+ # {NotImplementedError} stub?
802
+ #
803
+ # Never test for this with a bare +respond_to?+ — the interface module
804
+ # *defines* every method as a raising stub, so every adapter (including
805
+ # pgvector/Qdrant, which implement +each_id+ but not +each_entry+)
806
+ # answers +true+. That was B-108: {.populate_reloaded_vector_metadata}'s
807
+ # guard used to be +respond_to?(:each_entry)+, which crashed the +reload+
808
+ # tool the moment a durable backend was configured — +NotImplementedError+
809
+ # is a +ScriptError+, which escapes every +rescue StandardError+ between
810
+ # here and the stdio transport loop and killed the server. Mirrors
811
+ # {Woods::Embedding::Indexer#implements_own?}.
812
+ #
813
+ # @param object [Object]
814
+ # @param method_name [Symbol]
815
+ # @return [Boolean]
816
+ def self.implements_own?(object, method_name)
817
+ return false unless object.respond_to?(method_name)
818
+ return true unless defined?(Storage::VectorStore::Interface)
819
+
820
+ object.method(method_name).owner != Storage::VectorStore::Interface
821
+ end
822
+ private_class_method :implements_own?
823
+
824
+ # Reduce a metadata-store record to the SYMBOL-keyed subset the live
825
+ # embed path writes per vector — see
826
+ # {Embedding::Indexer#store_vectors}: +{ type:, identifier:, file_path: }+
827
+ # — plus +namespace:+, which only the backfill can supply (the store
828
+ # carries it per unit) and which namespace-filtered search after a
829
+ # dump/reload depends on.
830
+ #
831
+ # The metadata store returns STRING-keyed records on every real
832
+ # backend (SQLite round-trips through JSON.parse; InMemory stringifies
833
+ # on store), while {Retrieval::SearchExecutor#build_vector_filters}
834
+ # builds symbol-keyed filters and +VectorStore::InMemory#gather_candidates+
835
+ # probes +meta[:type]+ directly. Back-filling the raw string-keyed
836
+ # record therefore made every type-filtered +codebase_retrieve+ return
837
+ # EMPTY on a dump-hydrated server (#150 item 5) — the live embed path
838
+ # writes symbol keys, which is why in-process specs never saw it.
839
+ # Nil fields are dropped rather than stored.
840
+ #
841
+ # @param meta [Hash] Metadata record (string- or symbol-keyed)
842
+ # @return [Hash{Symbol => Object}] Symbol-keyed filter subset
843
+ def self.vector_filter_metadata(meta)
844
+ {
845
+ type: meta['type'] || meta[:type],
846
+ identifier: meta['identifier'] || meta[:identifier],
847
+ file_path: meta['file_path'] || meta[:file_path],
848
+ namespace: meta['namespace'] || meta[:namespace]
849
+ }.compact
850
+ end
851
+ private_class_method :vector_filter_metadata
852
+
373
853
  # Return a hydrated InMemory vector store when Shape 2 applies
374
854
  # (in-memory configured + artifact on disk + resolved config) —
375
855
  # otherwise nil, which tells Builder to construct a fresh one.
376
856
  # Durable backends (pgvector, Qdrant) never match this path.
377
- def self.hydrated_vector_store(config, resolved, artifact)
857
+ #
858
+ # A soft failure (transient I/O, corrupt dump) records the error on
859
+ # +state+ so the boot status reflects store health instead of reporting
860
+ # :hydrated over empty stores (M6). With +strict:+ (reload candidate
861
+ # path) a soft failure raises instead — a reload must never swap a
862
+ # good live store for an empty candidate (M7).
863
+ def self.hydrated_vector_store(config, resolved, artifact, state = nil, strict: false)
378
864
  return nil unless artifact && resolved
379
865
  return nil unless config.vector_store == :in_memory
380
866
 
@@ -385,12 +871,15 @@ module Woods
385
871
  # not a transient I/O issue. Operators must see these.
386
872
  raise
387
873
  rescue StandardError => e
874
+ raise if strict
875
+
388
876
  warn "[woods-mcp] vector hydration failed (#{e.class}: #{e.message}); starting with empty store"
877
+ state&.record_hydration_failure(:vector, e)
389
878
  nil
390
879
  end
391
880
  private_class_method :hydrated_vector_store
392
881
 
393
- def self.hydrated_metadata_store(config, resolved, artifact)
882
+ def self.hydrated_metadata_store(config, resolved, artifact, state = nil, strict: false)
394
883
  return nil unless artifact && resolved
395
884
  return nil unless config.metadata_store == :in_memory
396
885
 
@@ -398,7 +887,10 @@ module Woods
398
887
  rescue Woods::MCP::BootstrapError, ArgumentError
399
888
  raise
400
889
  rescue StandardError => e
890
+ raise if strict
891
+
401
892
  warn "[woods-mcp] metadata hydration failed (#{e.class}: #{e.message}); starting with empty store"
893
+ state&.record_hydration_failure(:metadata, e)
402
894
  nil
403
895
  end
404
896
  private_class_method :hydrated_metadata_store
@@ -413,6 +905,19 @@ module Woods
413
905
  'starting degraded — will retry on first query'
414
906
  end
415
907
  private_class_method :probe_and_mark_state
908
+
909
+ # Derive the final boot status from store health (M6). Reaching the
910
+ # provider is not enough to claim :hydrated: a hydration soft-failure
911
+ # left empty in-memory stores, and a "healthy" status on top of them
912
+ # presents a server that answers everything with nothing as fully
913
+ # operational. Provider-unreachable degradations keep their reason;
914
+ # only a false :hydrated is corrected.
915
+ def self.derive_state_from_store_health(state)
916
+ return unless state.hydration_failed? && state.status == :hydrated
917
+
918
+ state.mark(:degraded, reason: state.hydration_failures.values.first)
919
+ end
920
+ private_class_method :derive_state_from_store_health
416
921
  end
417
922
  end
418
923
  end