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
@@ -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
- dirname = latest_pointer_path.read.strip
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
- # Reads and parses +woods.json+, returning the raw hash.
92
+ # Path to a config snapshot embedded inside a dump directory.
86
93
  #
87
- # Returns +nil+ when the file does not exist. Schema-version validation
88
- # is the caller's responsibility (typically {Woods::ResolvedConfig.from_hash}).
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 = Pathname.new(dump_dir.to_s)
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
- json = raw.is_a?(String) ? raw : JSON.pretty_generate(raw)
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
- def initialize(app, token:)
20
- raise ArgumentError, 'token must be a non-empty string' if token.nil? || token.empty?
21
- if token.to_s.length < MIN_TOKEN_LENGTH
22
- raise ArgumentError,
23
- "bearer token must be at least #{MIN_TOKEN_LENGTH} characters " \
24
- "(got #{token.to_s.length}); generate with `SecureRandom.hex(32)`"
25
- end
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
- @token = token.to_s
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(@token, presented)
66
+ if expected && presented && Rack::Utils.secure_compare(expected, presented)
36
67
  @app.call(env)
37
68
  else
38
- [401,
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