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
data/lib/woods/index_artifact.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'pathname'
|
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'fileutils'
|
|
6
6
|
require 'tempfile'
|
|
7
|
+
require 'woods/atomic_file'
|
|
7
8
|
|
|
8
9
|
module Woods
|
|
9
10
|
# Whole Value for the on-disk artifact layout under +output_dir+.
|
|
@@ -75,23 +76,57 @@ module Woods
|
|
|
75
76
|
def latest_dump_path
|
|
76
77
|
return nil unless latest_pointer_path.exist?
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
# AtomicFile.read, never a bare Pathname#read (H1): the pointer sits on
|
|
80
|
+
# the load_or_empty path of both snapshotters, and a bare read tags its
|
|
81
|
+
# content with Encoding.default_external. The pointer content is
|
|
82
|
+
# generated (a strftime dirname), so ASCII by construction — the guard
|
|
83
|
+
# keeps the pattern out of the artifact-read surface rather than fixing
|
|
84
|
+
# an observed failure.
|
|
85
|
+
dirname = Woods::AtomicFile.read(latest_pointer_path).strip
|
|
79
86
|
return nil if dirname.empty?
|
|
80
87
|
|
|
81
88
|
dir = dumps_root.join(dirname)
|
|
82
89
|
dir.exist? ? dir : nil
|
|
83
90
|
end
|
|
84
91
|
|
|
85
|
-
#
|
|
92
|
+
# Path to a config snapshot embedded inside a dump directory.
|
|
86
93
|
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
94
|
+
# The vector/metadata dump and the root +woods.json+ used to be written
|
|
95
|
+
# as two independent atomic operations (dump, then +write_config+, then
|
|
96
|
+
# +promote+) — a crash between +write_config+ and +promote+ published a
|
|
97
|
+
# new config against the old dump. Writing the config snapshot here, as
|
|
98
|
+
# part of the dump itself, and promoting is what makes +promote+ the
|
|
99
|
+
# single commit point for both. See {#read_config}.
|
|
100
|
+
#
|
|
101
|
+
# @param dump_dir [Pathname, String] a dump directory (need not yet be promoted)
|
|
102
|
+
# @return [Pathname]
|
|
103
|
+
def dump_config_path(dump_dir)
|
|
104
|
+
Pathname.new(dump_dir.to_s).join('woods.json')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Reads and parses the resolved config, returning the raw hash.
|
|
108
|
+
#
|
|
109
|
+
# Prefers the config embedded in the *promoted* dump ({#latest_dump_path})
|
|
110
|
+
# over the root +woods.json+ — the dump's own copy is guaranteed to
|
|
111
|
+
# describe the vectors that dump holds, since {#promote} is the single
|
|
112
|
+
# commit point for both. Falls back to the root file for two cases: no
|
|
113
|
+
# dump has been promoted yet, or the promoted dump predates this fix and
|
|
114
|
+
# carries no embedded config at all (back-compat for existing dumps).
|
|
115
|
+
#
|
|
116
|
+
# Returns +nil+ when neither is present. Schema-version validation is the
|
|
117
|
+
# caller's responsibility (typically {Woods::ResolvedConfig.from_hash}).
|
|
89
118
|
#
|
|
90
119
|
# @return [Hash, nil]
|
|
91
120
|
def read_config
|
|
121
|
+
dump_dir = latest_dump_path
|
|
122
|
+
if dump_dir
|
|
123
|
+
embedded = dump_config_path(dump_dir)
|
|
124
|
+
return JSON.parse(embedded.read(encoding: Encoding::UTF_8)) if embedded.exist?
|
|
125
|
+
end
|
|
126
|
+
|
|
92
127
|
return nil unless config_path.exist?
|
|
93
128
|
|
|
94
|
-
JSON.parse(config_path.read)
|
|
129
|
+
JSON.parse(config_path.read(encoding: Encoding::UTF_8))
|
|
95
130
|
end
|
|
96
131
|
|
|
97
132
|
# Creates a new timestamped dump directory and returns its path.
|
|
@@ -112,6 +147,35 @@ module Woods
|
|
|
112
147
|
dir
|
|
113
148
|
end
|
|
114
149
|
|
|
150
|
+
# Validate that an existing dump directory resolves beneath
|
|
151
|
+
# {#dumps_root}. Both paths are resolved through realpath before the
|
|
152
|
+
# boundary comparison, so a symlinked alias of the artifact root remains
|
|
153
|
+
# valid while a child symlink escaping the root is rejected.
|
|
154
|
+
#
|
|
155
|
+
# This is the shared write/promotion boundary for the Snapshotter pair and
|
|
156
|
+
# {#promote}; keeping it here prevents their path policies from drifting.
|
|
157
|
+
#
|
|
158
|
+
# @param dump_dir [Pathname, String] existing dump directory to validate
|
|
159
|
+
# @param allow_root [Boolean] whether +dumps_root+ itself is an accepted target
|
|
160
|
+
# @return [Pathname] the caller-supplied path as a Pathname
|
|
161
|
+
# @raise [ArgumentError] if the path does not exist or resolves outside dumps_root
|
|
162
|
+
def validate_dump_dir!(dump_dir, allow_root: false)
|
|
163
|
+
target = Pathname.new(dump_dir.to_s)
|
|
164
|
+
root = dumps_root
|
|
165
|
+
target_real = target.exist? ? target.realpath.to_s : ''
|
|
166
|
+
root_real = root.exist? ? root.realpath.to_s : root.expand_path.to_s
|
|
167
|
+
contained = target_real.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
168
|
+
contained ||= allow_root && target_real == root_real
|
|
169
|
+
|
|
170
|
+
unless target.exist? && contained
|
|
171
|
+
raise ArgumentError,
|
|
172
|
+
'dump_dir must exist inside dumps_root. ' \
|
|
173
|
+
"Got: #{dump_dir.inspect}, dumps_root: #{dumps_root}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
target
|
|
177
|
+
end
|
|
178
|
+
|
|
115
179
|
# Atomically flips the +latest+ pointer to the given dump directory.
|
|
116
180
|
#
|
|
117
181
|
# Uses a temp file + +File.rename+ so a crash mid-flip leaves the previous
|
|
@@ -122,20 +186,7 @@ module Woods
|
|
|
122
186
|
# @return [void]
|
|
123
187
|
# @raise [ArgumentError] if +dump_dir+ does not exist or is outside +dumps_root+
|
|
124
188
|
def promote(dump_dir)
|
|
125
|
-
target =
|
|
126
|
-
root_real = dumps_root.expand_path.to_s
|
|
127
|
-
target_real = target.exist? ? target.realpath.to_s : ''
|
|
128
|
-
# Resolve symlinks on both sides before comparing (handles macOS /tmp → /private/var)
|
|
129
|
-
root_resolved = Pathname.new(root_real).exist? ? Pathname.new(root_real).realpath.to_s : root_real
|
|
130
|
-
# Prefix must end at a path boundary — a bare start_with? would accept
|
|
131
|
-
# sibling directories like "dumps-backup" or "dumpsevil", and the
|
|
132
|
-
# basename-only pointer written below would then name a directory that
|
|
133
|
-
# doesn't exist under dumps_root.
|
|
134
|
-
unless target.exist? && target_real.start_with?("#{root_resolved}#{File::SEPARATOR}")
|
|
135
|
-
raise ArgumentError,
|
|
136
|
-
'dump_dir must exist inside dumps_root. ' \
|
|
137
|
-
"Got: #{dump_dir.inspect}, dumps_root: #{dumps_root}"
|
|
138
|
-
end
|
|
189
|
+
target = validate_dump_dir!(dump_dir)
|
|
139
190
|
|
|
140
191
|
atomic_write(latest_pointer_path, target.basename.to_s)
|
|
141
192
|
end
|
|
@@ -149,17 +200,32 @@ module Woods
|
|
|
149
200
|
# @param resolved_config_hash [#to_snapshot_json, Hash]
|
|
150
201
|
# @return [void]
|
|
151
202
|
def write_config(resolved_config_hash)
|
|
203
|
+
atomic_write(config_path, serialize_config(resolved_config_hash))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Atomically writes a resolved config hash INSIDE a dump directory, as
|
|
207
|
+
# part of the dump — see {#dump_config_path} for why. Callers write this
|
|
208
|
+
# before {#promote}, so the config a promoted dump carries is exactly the
|
|
209
|
+
# config that produced it.
|
|
210
|
+
#
|
|
211
|
+
# @param dump_dir [Pathname, String] a dump directory (need not yet be promoted)
|
|
212
|
+
# @param resolved_config_hash [#to_snapshot_json, Hash]
|
|
213
|
+
# @return [void]
|
|
214
|
+
def write_dump_config(dump_dir, resolved_config_hash)
|
|
215
|
+
atomic_write(dump_config_path(dump_dir), serialize_config(resolved_config_hash))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
def serialize_config(resolved_config_hash)
|
|
152
221
|
raw = if resolved_config_hash.respond_to?(:to_snapshot_json)
|
|
153
222
|
resolved_config_hash.to_snapshot_json
|
|
154
223
|
else
|
|
155
224
|
resolved_config_hash
|
|
156
225
|
end
|
|
157
|
-
|
|
158
|
-
atomic_write(config_path, json)
|
|
226
|
+
raw.is_a?(String) ? raw : JSON.pretty_generate(raw)
|
|
159
227
|
end
|
|
160
228
|
|
|
161
|
-
private
|
|
162
|
-
|
|
163
229
|
def atomic_write(path, content)
|
|
164
230
|
FileUtils.mkdir_p(path.dirname)
|
|
165
231
|
tmp = Tempfile.new('.woods-', path.dirname.to_s)
|
|
@@ -168,6 +234,10 @@ module Woods
|
|
|
168
234
|
tmp.fsync
|
|
169
235
|
tmp.close
|
|
170
236
|
File.rename(tmp.path, path.to_s)
|
|
237
|
+
# The class doc promises the dump directory is fully fsynced before the
|
|
238
|
+
# latest pointer flips; a renamed-but-unflushed directory entry would
|
|
239
|
+
# break that promise on a crash (M11).
|
|
240
|
+
Woods::AtomicFile.fsync_directory(path.dirname.to_s)
|
|
171
241
|
rescue StandardError
|
|
172
242
|
tmp&.close
|
|
173
243
|
tmp&.unlink
|
|
@@ -9,6 +9,14 @@ module Woods
|
|
|
9
9
|
#
|
|
10
10
|
# Uses Rack::Utils.secure_compare for constant-time comparison to avoid
|
|
11
11
|
# leaking token bytes via response-time side channels.
|
|
12
|
+
#
|
|
13
|
+
# The token may be a String (validated at construction — the standalone
|
|
14
|
+
# `woods-mcp-http` path) or a callable resolved on every request (the
|
|
15
|
+
# Rails railtie path, where the token is typically set in
|
|
16
|
+
# `config/initializers/woods.rb` and therefore does not exist yet when
|
|
17
|
+
# the middleware is inserted — see #183). A deferred token that resolves
|
|
18
|
+
# to nothing usable (nil, empty, or shorter than {MIN_TOKEN_LENGTH})
|
|
19
|
+
# fails closed: every guarded request gets 401.
|
|
12
20
|
class BearerAuth
|
|
13
21
|
UNAUTHORIZED_BODY = { jsonrpc: '2.0', error: { code: -32_001, message: 'Unauthorized' }, id: nil }.to_json.freeze
|
|
14
22
|
|
|
@@ -16,30 +24,111 @@ module Woods
|
|
|
16
24
|
# Matches OWASP "session ID entropy" guidance (>= 128 bits ≈ 32 hex chars).
|
|
17
25
|
MIN_TOKEN_LENGTH = 32
|
|
18
26
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
# @param app [#call] The next Rack app in the middleware stack
|
|
28
|
+
# @param token [String, #call] Bearer token, or a callable returning it.
|
|
29
|
+
# String tokens are validated eagerly (raises on nil/empty/short);
|
|
30
|
+
# callables are resolved on each request so a token configured after
|
|
31
|
+
# the middleware was inserted still takes effect.
|
|
32
|
+
# @param path [String, nil] When set, only requests whose PATH_INFO
|
|
33
|
+
# starts with this prefix are guarded — everything else passes
|
|
34
|
+
# straight through to the app. Nil (the default) guards every request.
|
|
35
|
+
# @param enabled [#call, nil] Optional request-time predicate. When it
|
|
36
|
+
# returns falsy the request passes through unguarded. Nil (the
|
|
37
|
+
# default) means always guard.
|
|
38
|
+
def initialize(app, token:, path: nil, enabled: nil)
|
|
27
39
|
@app = app
|
|
28
|
-
@
|
|
40
|
+
@path = path
|
|
41
|
+
@enabled = enabled
|
|
42
|
+
@warned_unusable_token = false
|
|
43
|
+
|
|
44
|
+
if token.respond_to?(:call)
|
|
45
|
+
@token_source = token
|
|
46
|
+
else
|
|
47
|
+
validate_static_token!(token)
|
|
48
|
+
static_token = token.to_s
|
|
49
|
+
@token_source = -> { static_token }
|
|
50
|
+
end
|
|
29
51
|
end
|
|
30
52
|
|
|
53
|
+
# Rack entry point. Out-of-scope requests (non-matching `path:` prefix
|
|
54
|
+
# or a falsy `enabled:` predicate) pass through untouched; guarded
|
|
55
|
+
# requests require a matching `Authorization: Bearer <token>` header.
|
|
56
|
+
#
|
|
57
|
+
# @param env [Hash] Rack environment
|
|
58
|
+
# @return [Array] Rack response triple
|
|
31
59
|
def call(env)
|
|
60
|
+
return @app.call(env) unless guard?(env)
|
|
61
|
+
|
|
62
|
+
expected = resolve_token
|
|
32
63
|
header = env['HTTP_AUTHORIZATION'].to_s
|
|
33
64
|
presented = header.start_with?('Bearer ') ? header.sub(/\ABearer /, '') : nil
|
|
34
65
|
|
|
35
|
-
if presented && Rack::Utils.secure_compare(
|
|
66
|
+
if expected && presented && Rack::Utils.secure_compare(expected, presented)
|
|
36
67
|
@app.call(env)
|
|
37
68
|
else
|
|
38
|
-
|
|
39
|
-
{ 'content-type' => 'application/json', 'www-authenticate' => 'Bearer realm="woods-mcp-http"' },
|
|
40
|
-
[UNAUTHORIZED_BODY]]
|
|
69
|
+
unauthorized
|
|
41
70
|
end
|
|
42
71
|
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# @param env [Hash] Rack environment
|
|
76
|
+
# @return [Boolean] whether this request falls under the guard
|
|
77
|
+
def guard?(env)
|
|
78
|
+
return false if @path && !env['PATH_INFO'].to_s.start_with?(@path)
|
|
79
|
+
return false if @enabled && !@enabled.call
|
|
80
|
+
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Resolve the expected token for this request. Static tokens were
|
|
85
|
+
# validated at construction; deferred tokens are re-checked here and
|
|
86
|
+
# an unusable value fails closed (nil => 401 for every request).
|
|
87
|
+
#
|
|
88
|
+
# @return [String, nil]
|
|
89
|
+
def resolve_token
|
|
90
|
+
token = @token_source.call.to_s
|
|
91
|
+
return token if token.length >= MIN_TOKEN_LENGTH
|
|
92
|
+
|
|
93
|
+
warn_unusable_token(token)
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @param token [String] the eagerly-supplied token
|
|
98
|
+
# @raise [ArgumentError] on nil, empty, or too-short tokens
|
|
99
|
+
def validate_static_token!(token)
|
|
100
|
+
raise ArgumentError, 'token must be a non-empty string' if token.nil? || token.empty?
|
|
101
|
+
return unless token.to_s.length < MIN_TOKEN_LENGTH
|
|
102
|
+
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"bearer token must be at least #{MIN_TOKEN_LENGTH} characters " \
|
|
105
|
+
"(got #{token.to_s.length}); generate with `SecureRandom.hex(32)`"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Warn (once per instance) that a deferred token is unusable, so the
|
|
109
|
+
# resulting wall of 401s is explicable from the logs.
|
|
110
|
+
#
|
|
111
|
+
# @param token [String] the resolved (unusable) token
|
|
112
|
+
# @return [void]
|
|
113
|
+
def warn_unusable_token(token)
|
|
114
|
+
return if @warned_unusable_token
|
|
115
|
+
|
|
116
|
+
@warned_unusable_token = true
|
|
117
|
+
reason = if token.empty?
|
|
118
|
+
'no bearer token is configured'
|
|
119
|
+
else
|
|
120
|
+
"the configured bearer token is shorter than #{MIN_TOKEN_LENGTH} characters"
|
|
121
|
+
end
|
|
122
|
+
warn "[Woods::MCP::BearerAuth] refusing guarded requests (401): #{reason}. " \
|
|
123
|
+
'Set a 32+ character token (e.g. `SecureRandom.hex(32)`).'
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [Array] 401 Rack response triple
|
|
127
|
+
def unauthorized
|
|
128
|
+
[401,
|
|
129
|
+
{ 'content-type' => 'application/json', 'www-authenticate' => 'Bearer realm="woods-mcp-http"' },
|
|
130
|
+
[UNAUTHORIZED_BODY]]
|
|
131
|
+
end
|
|
43
132
|
end
|
|
44
133
|
end
|
|
45
134
|
end
|
|
@@ -41,12 +41,85 @@ module Woods
|
|
|
41
41
|
# {Woods.configuration}.
|
|
42
42
|
attr_accessor :resolved_config
|
|
43
43
|
|
|
44
|
+
# Store-hydration soft failures recorded during boot, keyed by store
|
|
45
|
+
# component (+:vector+, +:metadata+, +:graph+) with the exception that
|
|
46
|
+
# caused each. A recorded failure leaves the retriever serving empty
|
|
47
|
+
# stores; {#hydration_failed?} is the signal +woods_status+ and
|
|
48
|
+
# +codebase_retrieve+ use to stop presenting that as healthy (M6).
|
|
49
|
+
attr_reader :hydration_failures
|
|
50
|
+
|
|
51
|
+
# Reload-phase degraded condition (M7), or +nil+. Shape:
|
|
52
|
+
# +{ phase: 'reload', generation:, stores:, reason: }+ — the generation
|
|
53
|
+
# still being served, the store component(s) whose refresh failed, and
|
|
54
|
+
# the formatted reason. Deliberately separate from {#hydration_failures}
|
|
55
|
+
# and from +status+: a failed reload leaves the PREVIOUS fully aligned
|
|
56
|
+
# generation being served, so the boot degraded state must not flip and
|
|
57
|
+
# +codebase_retrieve+ must keep answering from the still-healthy old
|
|
58
|
+
# stores. +woods_status+ exposes this additively; a successful reload
|
|
59
|
+
# clears it.
|
|
60
|
+
attr_reader :reload_failure
|
|
61
|
+
|
|
44
62
|
def initialize
|
|
45
63
|
@status = :initializing
|
|
46
64
|
@reason = nil
|
|
47
65
|
@hydrated_at = nil
|
|
48
66
|
@degraded_since = nil
|
|
49
67
|
@resolved_config = nil
|
|
68
|
+
@hydration_failures = {}
|
|
69
|
+
@reload_failure = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Record a store-hydration soft failure. Kept separately from {#reason}
|
|
73
|
+
# so a provider-unreachable degradation and a hydration degradation can
|
|
74
|
+
# coexist without overwriting each other's evidence.
|
|
75
|
+
#
|
|
76
|
+
# @param component [Symbol, String] which store failed to hydrate
|
|
77
|
+
# @param error [Exception] the rescued hydration error
|
|
78
|
+
# @return [self]
|
|
79
|
+
def record_hydration_failure(component, error)
|
|
80
|
+
@hydration_failures[component.to_sym] = error
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Did any store fail to hydrate at boot?
|
|
85
|
+
#
|
|
86
|
+
# @return [Boolean]
|
|
87
|
+
def hydration_failed?
|
|
88
|
+
!@hydration_failures.empty?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Record a failed reload attempt (M7). The reload transaction is
|
|
92
|
+
# all-or-nothing: the failure means the previous generation is still
|
|
93
|
+
# being served, which is what +generation+ names.
|
|
94
|
+
#
|
|
95
|
+
# @param generation [Integer] generation still being served
|
|
96
|
+
# @param stores [Array<Symbol, String>] store component(s) whose
|
|
97
|
+
# refresh failed
|
|
98
|
+
# @param reason [String] formatted failure reason (+Class: message+)
|
|
99
|
+
# @return [self]
|
|
100
|
+
def record_reload_failure(generation:, stores:, reason:)
|
|
101
|
+
@reload_failure = {
|
|
102
|
+
phase: 'reload',
|
|
103
|
+
generation: generation,
|
|
104
|
+
stores: Array(stores).map(&:to_s),
|
|
105
|
+
reason: reason
|
|
106
|
+
}
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Clear the reload-phase degraded condition after a successful reload.
|
|
111
|
+
#
|
|
112
|
+
# @return [self]
|
|
113
|
+
def clear_reload_failure!
|
|
114
|
+
@reload_failure = nil
|
|
115
|
+
self
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Did the most recent reload attempt fail? Cleared by the next success.
|
|
119
|
+
#
|
|
120
|
+
# @return [Boolean]
|
|
121
|
+
def reload_failed?
|
|
122
|
+
!@reload_failure.nil?
|
|
50
123
|
end
|
|
51
124
|
|
|
52
125
|
# Transition to a new status.
|
|
@@ -87,6 +160,10 @@ module Woods
|
|
|
87
160
|
h[:reason] = "#{@reason.class}: #{@reason.message}" if @reason
|
|
88
161
|
h[:hydrated_at] = @hydrated_at.iso8601 if @hydrated_at
|
|
89
162
|
h[:degraded_since] = @degraded_since.iso8601 if @degraded_since
|
|
163
|
+
if hydration_failed?
|
|
164
|
+
h[:hydration_failures] = @hydration_failures.transform_values { |e| "#{e.class}: #{e.message}" }
|
|
165
|
+
end
|
|
166
|
+
h[:reload_failure] = @reload_failure.dup if @reload_failure
|
|
90
167
|
h
|
|
91
168
|
end
|
|
92
169
|
end
|