woods 2.0.0.beta2 → 2.0.0.beta4

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +339 -1
  3. data/CONTRIBUTING.md +188 -12
  4. data/README.md +93 -174
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +109 -8
  7. data/docs/AGENT_SETUP.md +98 -7
  8. data/docs/BACKEND_MATRIX.md +25 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +267 -16
  11. data/docs/CONSOLE_MCP_SETUP.md +80 -7
  12. data/docs/DOCKER_SETUP.md +22 -3
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +45 -6
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +147 -7
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +276 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +37 -22
  22. data/docs/MCP_WORKTREE_SETUP.md +43 -83
  23. data/docs/NOTION_INTEGRATION.md +13 -0
  24. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  25. data/docs/PUBLISHED_INDEX.md +72 -0
  26. data/docs/README.md +7 -0
  27. data/docs/RETRIEVAL_GUIDE.md +273 -12
  28. data/docs/RUNTIME_TRACING.md +71 -0
  29. data/docs/SOURCE_FRESHNESS.md +143 -0
  30. data/docs/TROUBLESHOOTING.md +129 -18
  31. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  32. data/docs/UPGRADING_TO_2.md +48 -22
  33. data/docs/WATCH_DAEMON.md +277 -67
  34. data/exe/woods-agent-config +6 -0
  35. data/exe/woods-extract +5 -0
  36. data/exe/woods-hook-context +6 -0
  37. data/exe/woods-mcp-start +14 -9
  38. data/lib/generators/woods/pgvector_generator.rb +8 -2
  39. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  40. data/lib/tasks/woods.rake +47 -397
  41. data/lib/woods/agent_configuration/applier.rb +135 -0
  42. data/lib/woods/agent_configuration/cli.rb +101 -0
  43. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  44. data/lib/woods/agent_configuration/document.rb +105 -0
  45. data/lib/woods/agent_configuration/error.rb +7 -0
  46. data/lib/woods/agent_configuration/launcher.rb +75 -0
  47. data/lib/woods/agent_configuration/layout.rb +72 -0
  48. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  49. data/lib/woods/agent_configuration/plan.rb +98 -0
  50. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  51. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  52. data/lib/woods/agent_configuration/planner.rb +63 -0
  53. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  54. data/lib/woods/agent_configuration/preflight.rb +100 -0
  55. data/lib/woods/agent_configuration/recovery.rb +49 -0
  56. data/lib/woods/ast/node.rb +2 -0
  57. data/lib/woods/ast/parser.rb +38 -5
  58. data/lib/woods/builder.rb +21 -5
  59. data/lib/woods/cache/cache_middleware.rb +28 -7
  60. data/lib/woods/cache/cache_store.rb +4 -5
  61. data/lib/woods/change_set.rb +5 -4
  62. data/lib/woods/console/credential_index.rb +20 -2
  63. data/lib/woods/console/credential_scanner.rb +18 -17
  64. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  65. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  66. data/lib/woods/console/embedded_executor.rb +32 -10
  67. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  68. data/lib/woods/console/rack_middleware.rb +22 -13
  69. data/lib/woods/console/server.rb +18 -16
  70. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  71. data/lib/woods/console/sql_table_scanner.rb +47 -7
  72. data/lib/woods/console/sql_validator.rb +49 -9
  73. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  74. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  75. data/lib/woods/dependency_graph.rb +65 -13
  76. data/lib/woods/embedding/corpus.rb +94 -0
  77. data/lib/woods/embedding/indexer.rb +114 -60
  78. data/lib/woods/embedding/openai.rb +17 -6
  79. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  80. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  81. data/lib/woods/export/typed_reader.rb +56 -0
  82. data/lib/woods/extractor.rb +277 -149
  83. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  84. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  85. data/lib/woods/extractors/caching_extractor.rb +3 -1
  86. data/lib/woods/extractors/concern_extractor.rb +64 -6
  87. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  88. data/lib/woods/extractors/controller_extractor.rb +13 -4
  89. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  90. data/lib/woods/extractors/declared_parent.rb +55 -0
  91. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  92. data/lib/woods/extractors/engine_extractor.rb +3 -1
  93. data/lib/woods/extractors/event_extractor.rb +4 -2
  94. data/lib/woods/extractors/factory_extractor.rb +3 -1
  95. data/lib/woods/extractors/graphql_extractor.rb +10 -13
  96. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  97. data/lib/woods/extractors/job_extractor.rb +6 -19
  98. data/lib/woods/extractors/lib_extractor.rb +13 -9
  99. data/lib/woods/extractors/mailer_extractor.rb +26 -15
  100. data/lib/woods/extractors/manager_extractor.rb +3 -1
  101. data/lib/woods/extractors/method_parameters.rb +53 -0
  102. data/lib/woods/extractors/middleware_argument.rb +65 -0
  103. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  104. data/lib/woods/extractors/migration_extractor.rb +3 -1
  105. data/lib/woods/extractors/model_extractor.rb +26 -34
  106. data/lib/woods/extractors/package_extractor.rb +24 -4
  107. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  108. data/lib/woods/extractors/policy_extractor.rb +3 -1
  109. data/lib/woods/extractors/poro_extractor.rb +13 -9
  110. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  111. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  112. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  113. data/lib/woods/extractors/route_extractor.rb +3 -1
  114. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  115. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  116. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  117. data/lib/woods/extractors/service_extractor.rb +3 -1
  118. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  119. data/lib/woods/extractors/shared_utility_methods.rb +48 -19
  120. data/lib/woods/extractors/source_nesting.rb +1 -1
  121. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  122. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  123. data/lib/woods/extractors/validator_extractor.rb +3 -1
  124. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  125. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  126. data/lib/woods/gem_mapper.rb +2 -0
  127. data/lib/woods/git_history.rb +116 -0
  128. data/lib/woods/graph_analyzer.rb +35 -6
  129. data/lib/woods/hooks/context_cli.rb +54 -0
  130. data/lib/woods/hooks/context_event.rb +88 -0
  131. data/lib/woods/hooks/context_hint.rb +73 -0
  132. data/lib/woods/hooks/context_impact.rb +77 -0
  133. data/lib/woods/hooks/context_output.rb +47 -0
  134. data/lib/woods/hooks/context_state.rb +102 -0
  135. data/lib/woods/hooks/refresh.rb +79 -0
  136. data/lib/woods/hooks/rule_projection.rb +78 -0
  137. data/lib/woods/input_rules.rb +19 -0
  138. data/lib/woods/mcp/bearer_auth.rb +22 -13
  139. data/lib/woods/mcp/bootstrapper.rb +79 -4
  140. data/lib/woods/mcp/config_resolver.rb +2 -1
  141. data/lib/woods/mcp/index_reader.rb +334 -162
  142. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  143. data/lib/woods/mcp/origin_guard.rb +17 -9
  144. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  145. data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
  146. data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
  147. data/lib/woods/mcp/search_results.rb +74 -0
  148. data/lib/woods/mcp/server.rb +178 -63
  149. data/lib/woods/mcp/tool_contract.rb +3 -1
  150. data/lib/woods/mcp/tool_response_renderer.rb +41 -0
  151. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  152. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  153. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  154. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  155. data/lib/woods/mcp/traversal_response.rb +22 -0
  156. data/lib/woods/notion/exporter.rb +56 -17
  157. data/lib/woods/obsidian/destination_plan.rb +98 -0
  158. data/lib/woods/obsidian/name_mapper.rb +19 -3
  159. data/lib/woods/obsidian/note_builder.rb +19 -10
  160. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  161. data/lib/woods/operator/pipeline_guard.rb +18 -13
  162. data/lib/woods/path_dispatcher.rb +13 -6
  163. data/lib/woods/payload_store.rb +27 -26
  164. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  165. data/lib/woods/published_index.rb +2 -2
  166. data/lib/woods/railtie.rb +3 -3
  167. data/lib/woods/railtie_support.rb +12 -12
  168. data/lib/woods/rake_helpers.rb +382 -0
  169. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  170. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  171. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  172. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  173. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  174. data/lib/woods/resilience/index_validator.rb +112 -23
  175. data/lib/woods/retrieval/context_assembler.rb +50 -15
  176. data/lib/woods/retrieval/lexical_assembler.rb +84 -0
  177. data/lib/woods/retrieval/lexical_index.rb +120 -0
  178. data/lib/woods/retrieval/ranker.rb +4 -2
  179. data/lib/woods/retrieval/scope.rb +108 -0
  180. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  181. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  182. data/lib/woods/retrieval/search_executor.rb +86 -27
  183. data/lib/woods/retrieval/source_evidence.rb +200 -0
  184. data/lib/woods/retriever.rb +98 -22
  185. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  186. data/lib/woods/session_tracer/file_store.rb +6 -1
  187. data/lib/woods/session_tracer/middleware.rb +10 -12
  188. data/lib/woods/session_tracer/redis_store.rb +22 -6
  189. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  190. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  191. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  192. data/lib/woods/source_inputs/consumer_errors.rb +31 -0
  193. data/lib/woods/source_inputs/handoff.rb +102 -0
  194. data/lib/woods/source_inputs/launcher.rb +157 -0
  195. data/lib/woods/source_inputs/manifest.rb +124 -0
  196. data/lib/woods/source_inputs/private_key.rb +55 -0
  197. data/lib/woods/source_inputs/scanner.rb +171 -0
  198. data/lib/woods/source_inputs/scopes.rb +71 -0
  199. data/lib/woods/source_inputs/session.rb +214 -0
  200. data/lib/woods/source_inputs/status.rb +84 -0
  201. data/lib/woods/source_inputs/verifier.rb +107 -0
  202. data/lib/woods/storage/metadata_store.rb +25 -25
  203. data/lib/woods/storage/pgvector.rb +35 -10
  204. data/lib/woods/storage/qdrant.rb +17 -7
  205. data/lib/woods/storage/vector_store.rb +18 -6
  206. data/lib/woods/tasks.rb +3 -2
  207. data/lib/woods/temporal/json_snapshot_store.rb +58 -9
  208. data/lib/woods/unblocked/exporter.rb +59 -70
  209. data/lib/woods/version.rb +1 -1
  210. data/lib/woods/watch/boot_snapshot.rb +52 -0
  211. data/lib/woods/watch/daemon.rb +154 -32
  212. data/lib/woods/watch/listen_watcher.rb +4 -0
  213. data/lib/woods/watch/polling_watcher.rb +5 -1
  214. data/lib/woods/watch/status.rb +20 -15
  215. data/lib/woods/watch/tree_scan.rb +21 -13
  216. data/lib/woods/watch/watcher.rb +4 -1
  217. data/lib/woods.rb +50 -11
  218. data/plugin/.claude-plugin/plugin.json +1 -1
  219. data/plugin/hooks/adapters/normalize.jq +15 -0
  220. data/plugin/hooks/adapters/normalize.rb +63 -0
  221. data/plugin/hooks/hooks.json +20 -0
  222. data/plugin/hooks/woods-context.sh +50 -0
  223. data/plugin/hooks/woods-input-rules.sh +159 -0
  224. data/plugin/hooks/woods-opencode.mjs +65 -0
  225. data/plugin/hooks/woods-post-edit.sh +2 -225
  226. data/plugin/hooks/woods-refresh.sh +260 -0
  227. data/plugin/hooks/woods-session-start.sh +47 -55
  228. data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
  229. data/plugin/skills/woods-diagnose/SKILL.md +319 -1
  230. data/plugin/skills/woods-investigate/SKILL.md +145 -0
  231. data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
  232. data/plugin/skills/woods-setup/SKILL.md +110 -6
  233. metadata +87 -5
@@ -119,40 +119,14 @@ module Woods
119
119
 
120
120
  private
121
121
 
122
- # Where this run's units are read from — the published generation's
123
- # payload, or the output root for a flat index.
124
- #
125
- # Globbing the output root would walk `payloads/` and ingest every
126
- # retained generation, so each unit would be embedded once per retained
127
- # payload. It also picks up `dumps/`, which holds no unit JSON but is
128
- # needlessly large to walk.
129
- #
130
- # @return [String]
131
- def units_dir
132
- Woods::Generation.new(output_dir: @output_dir).payload_dir.to_s
133
- end
134
-
135
122
  def load_units
136
- Dir.glob(File.join(units_dir, '**', '*.json')).filter_map do |path|
137
- next if File.basename(path) == 'checkpoint.json'
138
-
139
- # AtomicFile.read, not File.read: a bare read tags the bytes with the
140
- # process's default external encoding (US-ASCII under LANG=C), so a
141
- # single multibyte character in one unit raised EncodingError out of
142
- # JSON.parse and aborted the whole embed run.
143
- data = JSON.parse(AtomicFile.read(path))
144
- # Extraction output also contains index listings (_index.json arrays) and
145
- # summary files (manifest.json, dependency_graph.json, graph_analysis.json)
146
- # that live alongside per-unit JSON. Filter to the unit shape.
147
- data if data.is_a?(Hash) && data.key?('type') && data.key?('identifier')
148
- rescue JSON::ParserError, EncodingError => e
149
- warn "[woods] skipping unreadable unit file #{path} (#{e.class}: #{e.message})"
150
- nil
151
- end
123
+ require_relative 'corpus'
124
+
125
+ Corpus.new(@output_dir).load
152
126
  end
153
127
 
154
- # The invariant: **checkpoint.json never advances over a unit whose
155
- # vector was not durably stored.** Two things uphold it here.
128
+ # The invariant: **checkpoint.json advances only after the intended
129
+ # vector state (including an empty set) is durable.** Two things uphold it here.
156
130
  #
157
131
  # 1. Ordering. For a store whose only durable copy is the dump
158
132
  # (+persistable?+), the checkpoint is written *after*
@@ -170,25 +144,26 @@ module Woods
170
144
  #
171
145
  # 2. Trust, verified. {#checkpoint_satisfied?} honours a checkpoint hit
172
146
  # only when the durable artifact actually holds a vector for that
173
- # unit. A checkpoint that ran ahead of its dump — an older gem with
147
+ # unit, or preparation verifies that it intentionally has no text.
148
+ # A checkpoint that ran ahead of its dump — an older gem with
174
149
  # this bug, an interrupted promote, a store swap — self-heals into a
175
150
  # re-embed instead of stranding the unit forever.
176
151
  def process_units(units, incremental:)
177
152
  prepare_run(incremental: incremental)
178
- units = assign_storage_identities(units)
179
153
  checkpoint = incremental ? load_checkpoint : {}
154
+ units = assign_storage_identities(units, checkpoint: checkpoint)
180
155
  stats = { processed: 0, skipped: 0, errors: 0 }
181
156
 
182
157
  embed_batches(units, checkpoint, stats, incremental: incremental)
183
158
 
159
+ reconcile_empty_units(checkpoint)
184
160
  retire_legacy_identities
185
161
  report_checkpoint_misses
186
- vanished = incremental && persistable? ? drop_vanished_units : 0
162
+ vanished = persistable? ? drop_vanished_units(incremental: incremental) : 0
187
163
  persist_snapshot if persistable? && snapshot_worth_writing?(stats, vanished, incremental: incremental)
188
164
  # Durable backends have no dump to rewrite, so staleness has to be
189
- # removed from the store itself — on full runs too, since a full run
190
- # against pgvector/Qdrant does not start from an empty store the way
191
- # the in-memory path does (#211).
165
+ # removed from the store itself — on full runs too, since pgvector/
166
+ # Qdrant retain rows instead of replacing a published dump (#211).
192
167
  reconcile_durable_store if reconcilable?
193
168
  save_checkpoint(checkpoint)
194
169
 
@@ -196,12 +171,12 @@ module Woods
196
171
  end
197
172
 
198
173
  # Unambiguous existing keys stay stable. A collision uses reversible typed keys.
199
- def assign_storage_identities(units)
174
+ def assign_storage_identities(units, checkpoint:)
200
175
  counts = units.group_by { |unit| unit['identifier'] }.transform_values(&:size)
201
176
  units.map do |unit|
202
177
  id = unit['identifier']
203
178
  typed = StorageIdentity.key(id, unit['type'])
204
- existing = known_storage_key?(typed)
179
+ existing = known_storage_key?(typed, checkpoint: checkpoint)
205
180
  next unit unless counts[id] > 1 || existing || id.start_with?(StorageIdentity::PREFIX)
206
181
 
207
182
  unit.merge('storage_id' => typed)
@@ -224,8 +199,8 @@ module Woods
224
199
  end
225
200
  end
226
201
 
227
- def known_storage_key?(key)
228
- (@persisted_ids || {}).key?(key) || (@durable_ids || {}).key?(key)
202
+ def known_storage_key?(key, checkpoint:)
203
+ (@persisted_ids || {}).key?(key) || (@durable_ids || {}).key?(key) || checkpoint.key?(key)
229
204
  end
230
205
 
231
206
  def retire_legacy_key(legacy)
@@ -243,11 +218,9 @@ module Woods
243
218
  # `woods:embed_incremental` runs evict every genuinely older dump in
244
219
  # favour of copies of the same state.
245
220
  #
246
- # Safe because nothing else mutates the *vector* store on a zero-processed
247
- # run: `prune_superseded_vectors` is reached only from `store_vectors`,
248
- # which runs only for items that were actually embedded. A checkpoint
249
- # self-heal counts as processed, so a run that re-embeds a stranded unit
250
- # still dumps.
221
+ # A zero-text transition can retire vectors without a provider call;
222
+ # @vectors_changed captures that case. Checkpoint self-heals still count
223
+ # as processed, so a run re-embedding a stranded unit also dumps.
251
224
  #
252
225
  # But "nothing embedded" is not "nothing changed" (B-069). `persist_snapshot`
253
226
  # writes the vector dump, the *metadata* dump and the config into one
@@ -266,6 +239,11 @@ module Woods
266
239
  # `@current_identifiers` is what this run saw. Anything in the first and
267
240
  # not the second is stale, and the dump has to be rewritten to drop it.
268
241
  #
242
+ # Existing units can also change metadata without changing source (B-119).
243
+ # Compare their complete records with the promoted metadata snapshot;
244
+ # publishing those changes must not require a provider call or invalidate
245
+ # the source-hash checkpoint contract.
246
+ #
269
247
  # Full runs always dump: they rebuild the store from scratch, so "nothing
270
248
  # processed" there means the store is genuinely empty and the dump must
271
249
  # say so rather than leave a stale one promoted.
@@ -274,7 +252,7 @@ module Woods
274
252
  def snapshot_worth_writing?(stats, vanished, incremental:)
275
253
  return true unless incremental
276
254
 
277
- stats[:processed].positive? || vanished.positive?
255
+ stats[:processed].positive? || vanished.positive? || @metadata_changed || @vectors_changed
278
256
  end
279
257
 
280
258
  # Fraction of the persisted units the vanished-unit sweep may remove
@@ -296,20 +274,24 @@ module Woods
296
274
  # this costs no IO) and `@current_identifiers` is what this run saw.
297
275
  # Pruning with an empty fresh-id list removes every chunk of the unit.
298
276
  #
299
- # Guarded by {#vanished_prune_permitted?} (B-079 / #191) — a refused
300
- # prune returns 0, which reads as "nothing vanished" to
277
+ # Incremental runs use {#vanished_prune_permitted?} (B-079 / #191).
278
+ # A refused prune returns 0, which reads as "nothing vanished" to
301
279
  # {#snapshot_worth_writing?}, so a run that also embedded nothing writes
302
280
  # no dump and the retention window is not rotated over the good dumps.
303
281
  # The warn precedes the deletes: if the prune raises partway, the
304
282
  # operator still learns what it was doing.
305
283
  #
284
+ # Full rebuilds deliberately replace the complete corpus, including an
285
+ # empty corpus, even when callers reuse their in-memory stores.
286
+ #
287
+ # @param incremental [Boolean] whether to apply the incremental purge guard
306
288
  # @return [Integer] how many units were dropped
307
- def drop_vanished_units
308
- return 0 if @persisted_ids.nil? || @persisted_ids.empty?
289
+ def drop_vanished_units(incremental:)
290
+ return 0 if @persisted_ids.empty?
309
291
 
310
292
  vanished = @persisted_ids.keys.reject { |identifier| @current_identifiers.include?(identifier) }
311
293
  return 0 if vanished.empty?
312
- return 0 unless vanished_prune_permitted?(vanished)
294
+ return 0 if incremental && !vanished_prune_permitted?(vanished)
313
295
 
314
296
  warn "[woods] dropping #{vanished.size} unit(s) from the vector index that the " \
315
297
  'extraction no longer holds; rewriting the dump.'
@@ -342,8 +324,8 @@ module Woods
342
324
  # Refusal never loses data: the stale vectors stay hydrated in the
343
325
  # store, so any dump this run does write (for freshly embedded work)
344
326
  # still carries them. Full runs never reach this guard —
345
- # +drop_vanished_units+ runs only on the incremental path, and a full
346
- # run's empty store is genuinely empty and must be dumped as such.
327
+ # a full run replaces the complete corpus and must publish even an empty
328
+ # result. Its vanished-unit reconciliation bypasses this guard.
347
329
  #
348
330
  # @param vanished [Array<String>] identifiers about to be pruned
349
331
  # @return [Boolean] true when the prune may proceed
@@ -378,9 +360,9 @@ module Woods
378
360
 
379
361
  # Delete vectors a durable store holds for units the index no longer has.
380
362
  #
381
- # The dump-backed path gets staleness removal for free: the dump is
382
- # rewritten from the live store each run, so a dropped unit simply stops
383
- # being written. A durable backend has no such rewrite — rows in
363
+ # The dump-backed path removes vanished units from its hydrated or
364
+ # reused stores before publishing a replacement dump. A durable backend
365
+ # has no such rewrite — rows in
384
366
  # `woods_vectors` and points in Qdrant survive until something deletes
385
367
  # them, which nothing did. A unit deleted from the codebase therefore
386
368
  # stayed retrievable through `codebase_retrieve` indefinitely, *including
@@ -489,10 +471,43 @@ module Woods
489
471
  @current_identifiers = Set.new
490
472
  @durable_ids = nil
491
473
  @checkpoint_misses = 0
492
- hydrate_persisted_vectors if incremental && persistable?
474
+ @metadata_changed = false
475
+ @vectors_changed = false
476
+ @empty_units = {}
477
+ @persisted_metadata = nil
478
+ prepare_snapshot_stores(incremental: incremental)
479
+ retain_metadata_identities
493
480
  load_durable_store_ids if reconcilable?
494
481
  end
495
482
 
483
+ def prepare_snapshot_stores(incremental:)
484
+ return unless persistable?
485
+
486
+ if incremental
487
+ hydrate_persisted_metadata
488
+ hydrate_persisted_vectors
489
+ else
490
+ # A direct caller may reuse an in-memory adapter for a full run.
491
+ # Its old chunks still need replacement, including by an empty set.
492
+ entries = []
493
+ @vector_store.each_entry { |id, _vector, _metadata| entries << { id: id } }
494
+ @persisted_ids = index_ids_by_identifier(entries)
495
+ retain_existing_metadata_identities
496
+ end
497
+ end
498
+
499
+ def retain_existing_metadata_identities
500
+ return unless @metadata_store.respond_to?(:each_entry)
501
+
502
+ @metadata_store.each_entry { |identifier, _unit| @persisted_ids[identifier] ||= [] }
503
+ end
504
+
505
+ # Source-empty units retain metadata but intentionally have no vectors.
506
+ # Keep those identities in the same deletion and typed-key accounting.
507
+ def retain_metadata_identities
508
+ @persisted_metadata&.each_entry { |identifier, _unit| @persisted_ids[identifier] ||= [] }
509
+ end
510
+
496
511
  # Read back what the durable store currently holds, as base identifiers.
497
512
  #
498
513
  # One enumeration serves both halves of #211:
@@ -618,7 +633,10 @@ module Woods
618
633
  # enumeration that failed) — fall back to trusting the checkpoint.
619
634
  return true if known_ids.nil?
620
635
 
621
- return true if known_ids.key?(storage_id(unit_data))
636
+ return true if known_ids[storage_id(unit_data)]&.any?
637
+ # A source-empty unit intentionally has no vector. Verify that state
638
+ # again rather than treating a missing nonempty vector as a cache hit.
639
+ return true if prepare_texts(unit_data).empty?
622
640
 
623
641
  @checkpoint_misses += 1
624
642
  false
@@ -635,7 +653,22 @@ module Woods
635
653
  def persist_unit_metadata(unit_data)
636
654
  return unless @metadata_store
637
655
 
638
- @metadata_store.store(storage_id(unit_data), unit_data)
656
+ id = storage_id(unit_data)
657
+ @metadata_changed ||= @persisted_metadata && @persisted_metadata.find(id) != unit_data
658
+ @metadata_store.store(id, unit_data)
659
+ end
660
+
661
+ # Compare with the promoted artifact, not a fresh metadata store or the
662
+ # source-only checkpoint. Retain old records until the guarded vanished
663
+ # sweep permits their deletion, just as vector hydration does.
664
+ def hydrate_persisted_metadata
665
+ return unless @metadata_store.respond_to?(:each_entry) && @metadata_store.respond_to?(:bulk_load)
666
+
667
+ require_relative '../index_artifact'
668
+ require_relative '../storage/snapshotter'
669
+
670
+ @persisted_metadata = Storage::Snapshotter::Metadata.load_or_empty(IndexArtifact.new(@output_dir))
671
+ @metadata_store.bulk_load(@persisted_metadata.each_entry)
639
672
  end
640
673
 
641
674
  # Refuse to index a unit whose real identifier already matches the
@@ -653,6 +686,7 @@ module Woods
653
686
  def collect_embed_items(unit_data, items)
654
687
  texts = prepare_texts(unit_data)
655
688
  identifier = storage_id(unit_data)
689
+ @empty_units[identifier] = unit_data['source_hash'] if texts.empty?
656
690
 
657
691
  texts.each_with_index do |text, idx|
658
692
  embed_id = texts.length > 1 ? "#{identifier}#chunk_#{idx}" : identifier
@@ -661,6 +695,26 @@ module Woods
661
695
  end
662
696
  end
663
697
 
698
+ # Defer zero-text deletion until every batch has prepared/embedded
699
+ # successfully. A later provider failure must not retire earlier vectors.
700
+ # Checkpoints retain their source-hash format; a matching no-vector hash
701
+ # is trusted only after verifying the current input still prepares empty.
702
+ def reconcile_empty_units(checkpoint)
703
+ verify_empty_reconciliation!
704
+ @empty_units.each do |identifier, source_hash|
705
+ @vectors_changed ||= @persisted_ids[identifier]&.any?
706
+ prune_identifier(identifier, []) if implements_own?(@vector_store, :delete)
707
+ prune_durable_identifier(identifier, []) if @durable_ids && implements_own?(@vector_store, :delete)
708
+ checkpoint[identifier] = source_hash
709
+ end
710
+ end
711
+
712
+ def verify_empty_reconciliation!
713
+ return unless @empty_units.any? && reconcilable? && @durable_ids.nil?
714
+
715
+ raise Woods::Error, 'Cannot reconcile source-empty units: existing vector IDs could not be read'
716
+ end
717
+
664
718
  def prepare_texts(unit_data) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
665
719
  unit = build_unit(unit_data)
666
720
  apply_chunking(unit) if @chunker && unit.chunks.empty? && needs_chunking?(unit)
@@ -16,7 +16,7 @@ module Woods
16
16
  # provider = Woods::Embedding::Provider::OpenAI.new(api_key: ENV['OPENAI_API_KEY'])
17
17
  # vector = provider.embed("class User < ApplicationRecord; end")
18
18
  # vectors = provider.embed_batch(["text1", "text2"])
19
- class OpenAI
19
+ class OpenAI # rubocop:disable Metrics/ClassLength
20
20
  include Interface
21
21
  include DiscardableClient
22
22
 
@@ -26,13 +26,20 @@ module Woods
26
26
  'text-embedding-3-small' => 1536,
27
27
  'text-embedding-3-large' => 3072
28
28
  }.freeze
29
- # OpenAI embedding models share an 8191-token input cap across
29
+ # Conservatively chunk below OpenAI's 8192-token input limit across
30
30
  # text-embedding-3-small / -3-large / ada-002. The chunker uses
31
- # this as a hard ceiling — the actual chunk size lands well
31
+ # 8191 as its ceiling — the actual chunk size lands well
32
32
  # below it once chars-per-token estimation and the prefix
33
33
  # allowance are factored in (see Builder#build_chunker).
34
34
  MAX_INPUT_TOKENS = 8191
35
35
 
36
+ # The API accepts at most 2048 inputs and 300,000 total tokens per
37
+ # request, with 8192 tokens per valid input. At most 36 inputs keeps
38
+ # even maximum-size texts below both limits without token estimates.
39
+ # https://developers.openai.com/api/reference/resources/embeddings/methods/create
40
+ MAX_REQUEST_INPUTS = 36
41
+ private_constant :MAX_REQUEST_INPUTS
42
+
36
43
  # @param api_key [String] OpenAI API key
37
44
  # @param model [String] OpenAI embedding model name (default: text-embedding-3-small)
38
45
  # @param dimensions [Integer, nil] Requested output size for text-embedding-3 models
@@ -57,7 +64,7 @@ module Woods
57
64
  vectors.first
58
65
  end
59
66
 
60
- # Embed multiple texts in a single request.
67
+ # Embed multiple texts in bounded requests, preserving input order.
61
68
  #
62
69
  # Sorts results by the index field to guarantee ordering matches input.
63
70
  #
@@ -71,8 +78,12 @@ module Woods
71
78
  raise ArgumentError, 'embed_batch(texts) rejects nil/empty entries (OpenAI returns 400)'
72
79
  end
73
80
 
74
- response = post_request(request_body(texts))
75
- extract_validated_batch(response, texts.size)
81
+ vectors = texts.each_slice(MAX_REQUEST_INPUTS).flat_map do |slice|
82
+ response = post_request(request_body(slice))
83
+ extract_validated_batch(response, slice.size)
84
+ end
85
+ VectorValidation.validate!(vectors, expected_count: texts.size, provider: 'OpenAI')
86
+ vectors
76
87
  end
77
88
 
78
89
  # Return the dimensionality of vectors produced by this model.
@@ -14,10 +14,14 @@ module Woods
14
14
  # @return [Integer, nil] the pid of the most recently spawned process
15
15
  attr_reader :pid
16
16
 
17
+ # @return [Integer, nil] process group owned by the current command
18
+ attr_reader :process_group_id
19
+
17
20
  # @param command [String] a full shell command line
18
21
  # @param chdir [String]
19
22
  # @return [Array(String, String, Boolean)] stdout, stderr, success
20
23
  def call(command, chdir:)
24
+ @pid = @process_group_id = nil
21
25
  stdout_r, stdout_w = IO.pipe
22
26
  stderr_r, stderr_w = IO.pipe
23
27
  spawn_child(command, chdir, stdout_w, stderr_w)
@@ -59,7 +63,8 @@ module Woods
59
63
  # terminated on timeout.
60
64
  def spawn_child(command, chdir, stdout_w, stderr_w)
61
65
  Thread.handle_interrupt(Timeout::Error => :never) do
62
- @pid = Process.spawn(command, chdir: chdir, in: File::NULL, out: stdout_w, err: stderr_w)
66
+ @pid = Process.spawn(command, chdir: chdir, in: File::NULL, out: stdout_w, err: stderr_w, pgroup: true)
67
+ @process_group_id = @pid
63
68
  end
64
69
  end
65
70
  end
@@ -16,7 +16,10 @@ module Woods
16
16
  # subprocess started by the wrapped executor keeps running unless
17
17
  # something kills it. When the wrapped executor exposes its most recent
18
18
  # pid (see {AblationExecutor}), this sends TERM, waits briefly, then KILL
19
- # if the process is still alive, before reporting the call as a timed-out
19
+ # if still alive. The default executor owns a process group, so cleanup
20
+ # includes ordinary descendants even if their parent already exited.
21
+ # Custom executors exposing only a pid retain single-process cleanup.
22
+ # Cleanup finishes before reporting the call as a timed-out
20
23
  # failure. An executor that does not expose a pid (for example a fake
21
24
  # executor in a spec) still gets the timeout, just without a process to
22
25
  # terminate.
@@ -47,15 +50,30 @@ module Woods
47
50
  pid = executor_pid
48
51
  return '' unless pid
49
52
 
50
- Process.kill('TERM', pid)
51
- Process.kill('KILL', pid) unless process_exited?(pid, within: TERM_GRACE_SECONDS)
53
+ target = signal_target(pid)
54
+ signal('TERM', target)
55
+ signal('KILL', target) unless process_exited?(target, within: TERM_GRACE_SECONDS)
52
56
  reap(pid, within: REAP_GRACE_SECONDS) ? '' : " (pid #{pid} did not reap within #{REAP_GRACE_SECONDS}s)"
53
57
  rescue Errno::ESRCH, Errno::ECHILD
54
58
  ''
55
59
  end
56
60
 
57
61
  def executor_pid
58
- @executor.pid if @executor.respond_to?(:pid)
62
+ pid = @executor.pid if @executor.respond_to?(:pid)
63
+ pid if pid.is_a?(Integer) && pid.positive?
64
+ end
65
+
66
+ # A group created by spawn(pgroup: true) is named after that child.
67
+ # Never infer a group from a custom pid or signal our own group.
68
+ def signal_target(pid)
69
+ group = @executor.process_group_id if @executor.respond_to?(:process_group_id)
70
+ group == pid && group != Process.getpgrp ? -group : pid
71
+ end
72
+
73
+ def signal(name, target)
74
+ Process.kill(name, target)
75
+ rescue Errno::ESRCH
76
+ nil
59
77
  end
60
78
 
61
79
  def process_exited?(pid, within:)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'woods/mcp/index_reader'
4
+
5
+ module Woods
6
+ module Export
7
+ # Typed reads for export consumers. Published identifiers remain unchanged.
8
+ class TypedReader
9
+ def initialize(reader)
10
+ @reader = reader
11
+ end
12
+
13
+ # Refuse missing or mismatched identities instead of exporting a sibling.
14
+ def find(identifier, type)
15
+ unit = @reader.find_unit(identifier, type: type)
16
+ unless unit.is_a?(Hash) && unit['identifier'] == identifier && unit['type'] == type
17
+ raise ExtractionError, "export unit missing or mismatched: #{type}:#{identifier}"
18
+ end
19
+
20
+ unit
21
+ rescue ArgumentError => e
22
+ raise ExtractionError, "export reader requires typed lookup: #{e.message}"
23
+ rescue IOError, SystemCallError, JSON::ParserError => e
24
+ raise ExtractionError, "export unit unreadable: #{type}:#{identifier}: #{e.message}"
25
+ end
26
+
27
+ # IndexReader validates every published artifact, including mixed buckets.
28
+ # The fallback supports injected readers with the documented list/find API.
29
+ def all(only: nil)
30
+ units = @reader.respond_to?(:each_unit) ? @reader.each_unit : injected_units(only)
31
+ units.select { |unit| !only || only.include?(unit['type']) }
32
+ rescue IOError, SystemCallError, JSON::ParserError => e
33
+ raise ExtractionError, "export index incomplete: #{e.message}"
34
+ end
35
+
36
+ private
37
+
38
+ def injected_units(only)
39
+ MCP::IndexReader::UNIT_TYPES_BY_DIR.flat_map do |dir, types|
40
+ next [] if only && (types & only).empty?
41
+
42
+ @reader.list_units(type: MCP::IndexReader::DIR_TO_TYPE.fetch(dir)).map do |entry|
43
+ find(entry['identifier'], entry_type(entry, types))
44
+ end
45
+ end
46
+ end
47
+
48
+ def entry_type(entry, types)
49
+ type = entry['type'] || (types.first if types.one?)
50
+ return type if types.include?(type)
51
+
52
+ raise ExtractionError, "export entry requires actual type: #{entry['identifier']}"
53
+ end
54
+ end
55
+ end
56
+ end