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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- 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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
149
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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::
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
return
|
|
450
|
+
def self.reload_vector_candidate(config, resolved, captured_dump, served)
|
|
451
|
+
return nil unless config.vector_store == :in_memory
|
|
193
452
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 :
|
|
462
|
+
private_class_method :reload_vector_candidate
|
|
199
463
|
|
|
200
|
-
def self.
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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 :
|
|
608
|
+
private_class_method :run_hook
|
|
212
609
|
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
223
|
-
|
|
616
|
+
captured.token == current.token
|
|
617
|
+
end
|
|
618
|
+
private_class_method :same_generation_marker?
|
|
224
619
|
|
|
225
|
-
|
|
226
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|