woods 2.0.0.beta2 → 2.0.0.beta3

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 (218) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +262 -1
  3. data/CONTRIBUTING.md +173 -9
  4. data/README.md +7 -3
  5. data/SECURITY.md +9 -6
  6. data/docs/AGENT_GUIDE.md +83 -4
  7. data/docs/AGENT_SETUP.md +82 -1
  8. data/docs/BACKEND_MATRIX.md +20 -0
  9. data/docs/CLIENT_HOOKS.md +111 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +199 -14
  11. data/docs/CONSOLE_MCP_SETUP.md +35 -5
  12. data/docs/DOCKER_SETUP.md +21 -2
  13. data/docs/EVALUATION.md +464 -1
  14. data/docs/EXTRACTOR_REFERENCE.md +36 -5
  15. data/docs/FAQ.md +11 -12
  16. data/docs/GETTING_STARTED.md +17 -5
  17. data/docs/INCREMENTAL_EXTRACTION.md +117 -1
  18. data/docs/INDEX_LAYOUT.md +382 -0
  19. data/docs/INTERNALS.md +7 -2
  20. data/docs/MCP_SERVERS.md +221 -5
  21. data/docs/MCP_TOOL_COOKBOOK.md +33 -18
  22. data/docs/NOTION_INTEGRATION.md +13 -0
  23. data/docs/OBSIDIAN_INTEGRATION.md +57 -9
  24. data/docs/PUBLISHED_INDEX.md +55 -0
  25. data/docs/README.md +7 -0
  26. data/docs/RETRIEVAL_GUIDE.md +253 -11
  27. data/docs/RUNTIME_TRACING.md +71 -0
  28. data/docs/SOURCE_FRESHNESS.md +143 -0
  29. data/docs/TROUBLESHOOTING.md +117 -5
  30. data/docs/UNBLOCKED_INTEGRATION.md +25 -0
  31. data/docs/UPGRADING_TO_2.md +44 -22
  32. data/docs/WATCH_DAEMON.md +259 -59
  33. data/exe/woods-agent-config +6 -0
  34. data/exe/woods-extract +5 -0
  35. data/exe/woods-hook-context +6 -0
  36. data/lib/generators/woods/templates/woods.rb.tt +1 -3
  37. data/lib/tasks/woods.rake +47 -397
  38. data/lib/woods/agent_configuration/applier.rb +133 -0
  39. data/lib/woods/agent_configuration/cli.rb +101 -0
  40. data/lib/woods/agent_configuration/cli_options.rb +29 -0
  41. data/lib/woods/agent_configuration/document.rb +105 -0
  42. data/lib/woods/agent_configuration/error.rb +7 -0
  43. data/lib/woods/agent_configuration/launcher.rb +75 -0
  44. data/lib/woods/agent_configuration/layout.rb +59 -0
  45. data/lib/woods/agent_configuration/managed_section.rb +62 -0
  46. data/lib/woods/agent_configuration/plan.rb +98 -0
  47. data/lib/woods/agent_configuration/plan_diff.rb +38 -0
  48. data/lib/woods/agent_configuration/planned_files.rb +61 -0
  49. data/lib/woods/agent_configuration/planner.rb +63 -0
  50. data/lib/woods/agent_configuration/planner_validation.rb +77 -0
  51. data/lib/woods/agent_configuration/preflight.rb +100 -0
  52. data/lib/woods/agent_configuration/recovery.rb +49 -0
  53. data/lib/woods/ast/node.rb +2 -0
  54. data/lib/woods/ast/parser.rb +38 -5
  55. data/lib/woods/builder.rb +21 -5
  56. data/lib/woods/cache/cache_middleware.rb +28 -7
  57. data/lib/woods/cache/cache_store.rb +4 -5
  58. data/lib/woods/change_set.rb +5 -4
  59. data/lib/woods/console/credential_index.rb +20 -2
  60. data/lib/woods/console/credential_scanner.rb +14 -14
  61. data/lib/woods/console/credential_scanner_registry.rb +36 -0
  62. data/lib/woods/console/embedded_executor.rb +1 -1
  63. data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
  64. data/lib/woods/console/rack_middleware.rb +22 -13
  65. data/lib/woods/console/server.rb +18 -16
  66. data/lib/woods/dependency_graph.rb +65 -13
  67. data/lib/woods/embedding/corpus.rb +94 -0
  68. data/lib/woods/embedding/indexer.rb +90 -46
  69. data/lib/woods/embedding/openai.rb +17 -6
  70. data/lib/woods/evaluation/ablation_executor.rb +6 -1
  71. data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
  72. data/lib/woods/export/typed_reader.rb +56 -0
  73. data/lib/woods/extractor.rb +232 -137
  74. data/lib/woods/extractors/action_cable_extractor.rb +3 -1
  75. data/lib/woods/extractors/behavioral_profile.rb +9 -7
  76. data/lib/woods/extractors/caching_extractor.rb +3 -1
  77. data/lib/woods/extractors/concern_extractor.rb +64 -6
  78. data/lib/woods/extractors/configuration_extractor.rb +7 -3
  79. data/lib/woods/extractors/controller_extractor.rb +13 -4
  80. data/lib/woods/extractors/database_view_extractor.rb +3 -1
  81. data/lib/woods/extractors/decorator_extractor.rb +3 -1
  82. data/lib/woods/extractors/engine_extractor.rb +3 -1
  83. data/lib/woods/extractors/event_extractor.rb +4 -2
  84. data/lib/woods/extractors/factory_extractor.rb +3 -1
  85. data/lib/woods/extractors/graphql_extractor.rb +8 -2
  86. data/lib/woods/extractors/i18n_extractor.rb +3 -1
  87. data/lib/woods/extractors/job_extractor.rb +6 -19
  88. data/lib/woods/extractors/lib_extractor.rb +3 -1
  89. data/lib/woods/extractors/mailer_extractor.rb +20 -5
  90. data/lib/woods/extractors/manager_extractor.rb +3 -1
  91. data/lib/woods/extractors/method_parameters.rb +53 -0
  92. data/lib/woods/extractors/middleware_argument.rb +65 -0
  93. data/lib/woods/extractors/middleware_extractor.rb +9 -3
  94. data/lib/woods/extractors/migration_extractor.rb +3 -1
  95. data/lib/woods/extractors/model_extractor.rb +39 -33
  96. data/lib/woods/extractors/package_extractor.rb +24 -4
  97. data/lib/woods/extractors/phlex_extractor.rb +3 -1
  98. data/lib/woods/extractors/policy_extractor.rb +3 -1
  99. data/lib/woods/extractors/poro_extractor.rb +3 -1
  100. data/lib/woods/extractors/pundit_extractor.rb +3 -1
  101. data/lib/woods/extractors/rails_source_extractor.rb +4 -2
  102. data/lib/woods/extractors/rake_task_extractor.rb +4 -2
  103. data/lib/woods/extractors/route_extractor.rb +3 -1
  104. data/lib/woods/extractors/route_helper_resolver.rb +10 -33
  105. data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
  106. data/lib/woods/extractors/serializer_extractor.rb +4 -2
  107. data/lib/woods/extractors/service_extractor.rb +3 -1
  108. data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
  109. data/lib/woods/extractors/shared_utility_methods.rb +27 -15
  110. data/lib/woods/extractors/source_nesting.rb +1 -1
  111. data/lib/woods/extractors/state_machine_extractor.rb +3 -1
  112. data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
  113. data/lib/woods/extractors/validator_extractor.rb +3 -1
  114. data/lib/woods/extractors/view_component_extractor.rb +3 -1
  115. data/lib/woods/extractors/view_template_extractor.rb +3 -1
  116. data/lib/woods/gem_mapper.rb +2 -0
  117. data/lib/woods/git_history.rb +116 -0
  118. data/lib/woods/graph_analyzer.rb +35 -6
  119. data/lib/woods/hooks/context_cli.rb +54 -0
  120. data/lib/woods/hooks/context_event.rb +88 -0
  121. data/lib/woods/hooks/context_hint.rb +73 -0
  122. data/lib/woods/hooks/context_impact.rb +77 -0
  123. data/lib/woods/hooks/context_output.rb +47 -0
  124. data/lib/woods/hooks/context_state.rb +102 -0
  125. data/lib/woods/hooks/refresh.rb +79 -0
  126. data/lib/woods/hooks/rule_projection.rb +78 -0
  127. data/lib/woods/input_rules.rb +19 -0
  128. data/lib/woods/mcp/bearer_auth.rb +20 -12
  129. data/lib/woods/mcp/bootstrapper.rb +62 -0
  130. data/lib/woods/mcp/index_reader.rb +323 -160
  131. data/lib/woods/mcp/initialization_guidance.rb +27 -0
  132. data/lib/woods/mcp/origin_guard.rb +17 -9
  133. data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
  134. data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
  135. data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
  136. data/lib/woods/mcp/search_results.rb +74 -0
  137. data/lib/woods/mcp/server.rb +158 -37
  138. data/lib/woods/mcp/tool_contract.rb +2 -0
  139. data/lib/woods/mcp/tool_response_renderer.rb +25 -0
  140. data/lib/woods/mcp/traversal_evidence.rb +113 -0
  141. data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
  142. data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
  143. data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
  144. data/lib/woods/notion/exporter.rb +56 -17
  145. data/lib/woods/obsidian/destination_plan.rb +98 -0
  146. data/lib/woods/obsidian/name_mapper.rb +19 -3
  147. data/lib/woods/obsidian/note_builder.rb +19 -10
  148. data/lib/woods/obsidian/vault_exporter.rb +88 -32
  149. data/lib/woods/operator/pipeline_guard.rb +18 -13
  150. data/lib/woods/path_dispatcher.rb +7 -1
  151. data/lib/woods/payload_store.rb +27 -26
  152. data/lib/woods/railtie.rb +3 -3
  153. data/lib/woods/railtie_support.rb +12 -12
  154. data/lib/woods/rake_helpers.rb +392 -0
  155. data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
  156. data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
  157. data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
  158. data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
  159. data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
  160. data/lib/woods/resilience/index_validator.rb +112 -23
  161. data/lib/woods/retrieval/context_assembler.rb +50 -15
  162. data/lib/woods/retrieval/lexical_assembler.rb +73 -0
  163. data/lib/woods/retrieval/lexical_index.rb +119 -0
  164. data/lib/woods/retrieval/ranker.rb +4 -2
  165. data/lib/woods/retrieval/scope.rb +108 -0
  166. data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
  167. data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
  168. data/lib/woods/retrieval/search_executor.rb +86 -27
  169. data/lib/woods/retrieval/source_evidence.rb +200 -0
  170. data/lib/woods/retriever.rb +98 -22
  171. data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
  172. data/lib/woods/session_tracer/middleware.rb +10 -12
  173. data/lib/woods/session_tracer/redis_store.rb +22 -6
  174. data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
  175. data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
  176. data/lib/woods/session_tracer/unit_resolver.rb +63 -0
  177. data/lib/woods/source_inputs/consumer_errors.rb +27 -0
  178. data/lib/woods/source_inputs/handoff.rb +102 -0
  179. data/lib/woods/source_inputs/launcher.rb +157 -0
  180. data/lib/woods/source_inputs/manifest.rb +124 -0
  181. data/lib/woods/source_inputs/private_key.rb +55 -0
  182. data/lib/woods/source_inputs/scanner.rb +171 -0
  183. data/lib/woods/source_inputs/scopes.rb +71 -0
  184. data/lib/woods/source_inputs/session.rb +214 -0
  185. data/lib/woods/source_inputs/status.rb +84 -0
  186. data/lib/woods/source_inputs/verifier.rb +107 -0
  187. data/lib/woods/storage/metadata_store.rb +25 -25
  188. data/lib/woods/storage/pgvector.rb +29 -8
  189. data/lib/woods/storage/qdrant.rb +17 -7
  190. data/lib/woods/storage/vector_store.rb +18 -6
  191. data/lib/woods/tasks.rb +3 -2
  192. data/lib/woods/temporal/json_snapshot_store.rb +29 -8
  193. data/lib/woods/unblocked/exporter.rb +59 -70
  194. data/lib/woods/version.rb +1 -1
  195. data/lib/woods/watch/boot_snapshot.rb +52 -0
  196. data/lib/woods/watch/daemon.rb +136 -28
  197. data/lib/woods/watch/listen_watcher.rb +4 -0
  198. data/lib/woods/watch/polling_watcher.rb +5 -1
  199. data/lib/woods/watch/status.rb +20 -15
  200. data/lib/woods/watch/tree_scan.rb +21 -13
  201. data/lib/woods/watch/watcher.rb +4 -1
  202. data/lib/woods.rb +50 -11
  203. data/plugin/.claude-plugin/plugin.json +1 -1
  204. data/plugin/hooks/adapters/normalize.jq +15 -0
  205. data/plugin/hooks/adapters/normalize.rb +63 -0
  206. data/plugin/hooks/hooks.json +20 -0
  207. data/plugin/hooks/woods-context.sh +50 -0
  208. data/plugin/hooks/woods-input-rules.sh +159 -0
  209. data/plugin/hooks/woods-opencode.mjs +65 -0
  210. data/plugin/hooks/woods-post-edit.sh +2 -225
  211. data/plugin/hooks/woods-refresh.sh +260 -0
  212. data/plugin/hooks/woods-session-start.sh +47 -55
  213. data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
  214. data/plugin/skills/woods-diagnose/SKILL.md +288 -1
  215. data/plugin/skills/woods-investigate/SKILL.md +106 -0
  216. data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
  217. data/plugin/skills/woods-setup/SKILL.md +107 -6
  218. metadata +84 -5
@@ -9,9 +9,11 @@ require_relative '../reload_policy'
9
9
  require_relative 'status'
10
10
  require_relative 'tree_scan'
11
11
  require_relative 'watcher'
12
+ require_relative 'boot_snapshot'
12
13
  require 'json'
13
14
  require 'set'
14
15
  require 'securerandom'
16
+ require 'timeout'
15
17
 
16
18
  module Woods
17
19
  module Watch
@@ -138,6 +140,9 @@ module Woods
138
140
  # which most daemon cycles are (a single-file cycle is milliseconds).
139
141
  HEARTBEAT_SHUTDOWN_TIMEOUT = 2
140
142
 
143
+ # Native registration or a polling baseline must finish before catch-up.
144
+ WATCHER_STARTUP_TIMEOUT = 30
145
+
141
146
  # @return [Woods::Generation]
142
147
  attr_reader :generation
143
148
 
@@ -156,6 +161,9 @@ module Woods
156
161
  # @param lock [Woods::Coordination::PipelineLock, nil] writer lock for
157
162
  # this index; built from {LOCK_NAME} when nil
158
163
  # @param catch_up [Boolean] reconcile changes that predate startup
164
+ # @param poll_interval [Float] finite, positive seconds between polling scans
165
+ # @param boot_snapshot [BootSnapshot, nil] captured before environment
166
+ # initialization; permits full reconciliation of unchanged startup inputs
159
167
  # @param force_polling [Boolean] never use the `listen` backend — the
160
168
  # right choice across a container bind mount, where native FS events
161
169
  # do not propagate
@@ -166,7 +174,9 @@ module Woods
166
174
  def initialize(output_dir:, root: nil, extractor_factory: nil, reloader: nil, watcher: nil,
167
175
  policy: ReloadPolicy.new, debounce: DEFAULT_DEBOUNCE,
168
176
  full_extraction_threshold: DEFAULT_FULL_EXTRACTION_THRESHOLD,
169
- idle_timeout: nil, lock: nil, catch_up: true, force_polling: false, logger: nil)
177
+ idle_timeout: nil, lock: nil, catch_up: true, force_polling: false,
178
+ boot_snapshot: nil, poll_interval: Watcher::DEFAULT_POLL_INTERVAL, logger: nil)
179
+ @poll_interval = validated_poll_interval(poll_interval)
170
180
  @output_dir = output_dir.to_s
171
181
  @root = (root || (defined?(Rails) ? Rails.root : Dir.pwd)).to_s
172
182
  @extractor_factory = extractor_factory || -> { Woods::Extractor.new(output_dir: @output_dir) }
@@ -177,6 +187,7 @@ module Woods
177
187
  @full_extraction_threshold = full_extraction_threshold
178
188
  @idle_timeout = idle_timeout
179
189
  @catch_up = catch_up
190
+ @boot_snapshot = boot_snapshot
180
191
  @force_polling = force_polling
181
192
  @logger = logger || default_logger
182
193
  @generation = Generation.new(output_dir: @output_dir)
@@ -231,11 +242,19 @@ module Woods
231
242
  # @return [Hash] `{ action:, state:, generation:, reason:, count:,
232
243
  # duration_ms: }`
233
244
  def process(paths = [])
245
+ process_batch(paths)
246
+ end
247
+
248
+ private
249
+
250
+ # Only the resident loop can discharge work covered by its explicit
251
+ # environment-boot snapshot. Direct #process calls remain conservative.
252
+ def process_batch(paths = [], startup: false)
234
253
  change_set = ChangeSet.new(paths: drain_with(paths), root: @root)
235
254
  cycle_completed = false
236
255
 
237
256
  begin
238
- result = case required_action(change_set)
257
+ result = case required_action(change_set, startup: startup)
239
258
  when :ignore then nothing_to_do
240
259
  when :restart then require_restart(change_set)
241
260
  when :reload then attempt_reload ? extract(change_set) : degraded_reload(change_set)
@@ -249,13 +268,20 @@ module Woods
249
268
  # shutdown's heartbeat.kill lands on a thread doing real work) would
250
269
  # otherwise lose the batch #drain_with already popped from @pending.
251
270
  # Every branch above that finishes normally already carries its own
252
- # paths forward on failure (or intentionally doesn't, e.g. :restart);
271
+ # paths forward on failure or restart;
253
272
  # this only fires for the abnormal case none of them can catch.
254
273
  carry_forward(change_set) unless cycle_completed
255
274
  end
256
275
  end
257
276
 
258
- private
277
+ def validated_poll_interval(value)
278
+ interval = Float(value)
279
+ unless interval.finite? && interval.positive?
280
+ raise ArgumentError, 'poll_interval (WOODS_WATCH_POLL_INTERVAL) must be finite and greater than zero'
281
+ end
282
+
283
+ interval
284
+ end
259
285
 
260
286
  # The watch loop proper, split from {#run} so the shutdown `ensure` —
261
287
  # publish `stopped`, persist carried paths — can only ever fire for a
@@ -273,11 +299,19 @@ module Woods
273
299
  @last_event_at = monotonic_now
274
300
  heartbeat = start_heartbeat
275
301
 
276
- watcher_thread = launch_watcher do |paths|
277
- enqueue(paths)
278
- drain
302
+ # Watch immediately, but establish startup obligations before any
303
+ # callback can drain them. Live callbacks enqueue under a separate mutex.
304
+ watcher_thread = nil
305
+ @drain_mutex.synchronize do
306
+ watcher_thread = launch_watcher do |paths|
307
+ enqueue(paths)
308
+ drain
309
+ end
310
+ await_watcher_ready
311
+ catch_up unless @stop_reason
312
+ drain_cycles
313
+ @last_event_at = monotonic_now
279
314
  end
280
- catch_up
281
315
  watcher_thread.join
282
316
  raise @watcher_failure if @watcher_failure
283
317
 
@@ -308,7 +342,7 @@ module Woods
308
342
  # plausibly) before reaching that line, and it must still happen
309
343
  # before #persist_pending — otherwise a watcher thread still draining
310
344
  # a batch races the shutdown snapshot of @pending.
311
- watcher_thread&.join
345
+ watcher_thread&.join(HEARTBEAT_SHUTDOWN_TIMEOUT) || watcher_thread&.kill&.join
312
346
  stop_heartbeat(heartbeat)
313
347
  # Before #persist_pending, for the same reason the watcher join is: a
314
348
  # retry drain still running would race the shutdown snapshot of
@@ -346,9 +380,18 @@ module Woods
346
380
  start_watching(&on_change)
347
381
  rescue StandardError => e
348
382
  @watcher_failure = e
383
+ ensure
384
+ @watcher_ready << :finished
349
385
  end
350
386
  end
351
387
 
388
+ def await_watcher_ready
389
+ Timeout.timeout(WATCHER_STARTUP_TIMEOUT) { @watcher_ready.pop }
390
+ raise @watcher_failure if @watcher_failure
391
+ rescue Timeout::Error
392
+ raise WatcherError, 'watcher did not establish its baseline before the startup timeout'
393
+ end
394
+
352
395
  # @param heartbeat [Thread, nil]
353
396
  # @return [void]
354
397
  def stop_heartbeat(heartbeat)
@@ -363,8 +406,12 @@ module Woods
363
406
  # `:reload` by restarting, since extracting against constants that no
364
407
  # longer match their source is the thing the classification exists to
365
408
  # prevent.
366
- def required_action(change_set)
367
- action = @policy.classify_all(change_set.relative_paths)
409
+ def required_action(change_set, startup: false)
410
+ paths = change_set.absolute_paths
411
+ paths = paths.reject { |path| startup_covered?(path) } if startup
412
+ relative = ChangeSet.new(paths: paths, root: @root).relative_paths
413
+ action = @policy.classify_all(relative)
414
+ action = :reextract if action == :ignore && @startup_full
368
415
  return :restart if action == :reload && !@reloader.enabled?
369
416
 
370
417
  action
@@ -386,10 +433,14 @@ module Woods
386
433
 
387
434
  def reset_cycle_state
388
435
  @pending = Set.new
436
+ @startup_paths = Set.new
437
+ @live_paths = Set.new
438
+ @startup_full = false
389
439
  @pending_mutex = Mutex.new
390
440
  @stop_reason = nil
391
441
  @drain_mutex = Mutex.new
392
442
  @retry_thread = nil
443
+ @watcher_ready = Queue.new
393
444
  end
394
445
 
395
446
  # Wait out the debounce window so events that land during it join the
@@ -405,9 +456,12 @@ module Woods
405
456
  end
406
457
 
407
458
  # Merge a watcher batch into the pending set without processing it.
408
- def enqueue(paths)
459
+ def enqueue(paths, startup: false)
409
460
  absolute = ChangeSet.new(paths: paths, root: @root).absolute_paths
410
- @pending_mutex.synchronize { @pending.merge(absolute) }
461
+ @pending_mutex.synchronize do
462
+ @pending.merge(absolute)
463
+ @live_paths.merge(absolute) unless startup
464
+ end
411
465
  @last_event_at = monotonic_now
412
466
  end
413
467
 
@@ -457,7 +511,7 @@ module Woods
457
511
  def drain_cycles
458
512
  until pending_empty? || @stop_reason
459
513
  settle
460
- result = process
514
+ result = process_batch(startup: true)
461
515
 
462
516
  if result[:action] == :restart
463
517
  # @watcher, not a captured local — start_watching's polling
@@ -530,6 +584,10 @@ module Woods
530
584
 
531
585
  carried = restore_pending
532
586
  paths = (uncovered_paths + carried).uniq
587
+ boot_changes = @boot_snapshot ? @boot_snapshot.changed_paths : []
588
+ enqueue(boot_changes) unless boot_changes.empty?
589
+ prepare_startup_reconciliation(paths)
590
+ paths |= boot_changes
533
591
  if paths.empty?
534
592
  return reconcile_deletions if stale_deletions?
535
593
 
@@ -537,10 +595,28 @@ module Woods
537
595
  end
538
596
 
539
597
  @logger.info("[Woods] watch: #{paths.size} path(s) changed before startup — catching up")
540
- enqueue(paths)
598
+ enqueue(paths, startup: true)
541
599
  drain
542
600
  end
543
601
 
602
+ def prepare_startup_reconciliation(paths)
603
+ return unless @boot_snapshot
604
+
605
+ restart_paths = paths.select do |path|
606
+ set = ChangeSet.new(paths: [path], root: @root)
607
+ required_action(set) == :restart && @boot_snapshot.covers?(path)
608
+ end
609
+ @pending_mutex.synchronize do
610
+ @startup_paths.merge(restart_paths)
611
+ @startup_full = restart_paths.any?
612
+ end
613
+ end
614
+
615
+ def startup_covered?(path)
616
+ eligible = @pending_mutex.synchronize { @startup_paths.include?(path) && !@live_paths.include?(path) }
617
+ eligible && @boot_snapshot.covers?(path)
618
+ end
619
+
544
620
  # A file deleted while nothing was watching leaves no mtime for the scan
545
621
  # to see — {TreeScan} only walks files that exist — so a deletion-only
546
622
  # downtime would log "index is current" while ghost units survive. The
@@ -759,7 +835,7 @@ module Woods
759
835
  # start at all (inotify exhaustion being the usual reason). A daemon that
760
836
  # costs some CPU beats one that silently never fires.
761
837
  def start_watching(&on_change)
762
- @watcher.start(&on_change)
838
+ start_backend(&on_change)
763
839
  rescue WatcherError => e
764
840
  raise if @polling_fallback
765
841
 
@@ -769,8 +845,20 @@ module Woods
769
845
  # the output-directory feedback loop {#ignored_directories} exists to
770
846
  # break, on precisely the path a large tree reaches.
771
847
  @watcher = Watcher.build(
772
- root: @root, ignored: ignored_directories, logger: @logger, force_polling: true
848
+ root: @root, ignored: ignored_directories, logger: @logger, force_polling: true,
849
+ poll_interval: @poll_interval
773
850
  )
851
+ start_backend(&on_change)
852
+ end
853
+
854
+ def start_backend(&on_change)
855
+ if @watcher.respond_to?(:ready_callback=)
856
+ @watcher.ready_callback = -> { @watcher_ready << :ready }
857
+ else
858
+ # Existing injected watchers own their startup contract. Built-in
859
+ # backends signal only after the actual baseline/registration.
860
+ @watcher_ready << :ready
861
+ end
774
862
  @watcher.start(&on_change)
775
863
  end
776
864
 
@@ -800,6 +888,7 @@ module Woods
800
888
  end
801
889
 
802
890
  def require_restart(change_set)
891
+ carry_forward(change_set)
803
892
  triggers = @policy.paths_requiring(change_set.relative_paths, :restart)
804
893
  reason = "restart required: #{triggers.first(5).join(', ')}"
805
894
  @logger.warn("[Woods] watch: #{reason}")
@@ -864,13 +953,7 @@ module Woods
864
953
  end
865
954
 
866
955
  def run_extraction(change_set, started)
867
- # Count only paths that imply extraction work. Sixty edited markdown
868
- # files plus one model is a one-model change, and reading it as a storm
869
- # would trade a millisecond cycle for a full extraction.
870
- actionable = actionable_count(change_set)
871
- full = actionable > @full_extraction_threshold
872
- log_storm(actionable) if full
873
-
956
+ full = full_extraction?(change_set)
874
957
  before = @generation.current.number
875
958
  extractor = @extractor_factory.call
876
959
  touched = if full
@@ -883,7 +966,7 @@ module Woods
883
966
  action = full ? :full : :incremental
884
967
  return unpublished(action, change_set, started) if wrote_without_publishing?(touched, before)
885
968
 
886
- publish(action, change_set, touched, started)
969
+ finish_extraction(action, change_set, touched, started)
887
970
  rescue ScriptError, StandardError => e
888
971
  # Extraction failed, so nothing landed — the generation stays where it
889
972
  # was and the index keeps serving its last good state.
@@ -896,6 +979,28 @@ module Woods
896
979
  duration_ms: elapsed_ms(started))
897
980
  end
898
981
 
982
+ def full_extraction?(change_set)
983
+ if @startup_full
984
+ @logger.info('[Woods] watch: environment boot covers startup changes — full extraction')
985
+ return true
986
+ end
987
+
988
+ # Ignorable paths do not make a storm out of a single model edit.
989
+ actionable = actionable_count(change_set)
990
+ full = actionable > @full_extraction_threshold
991
+ log_storm(actionable) if full
992
+ full
993
+ end
994
+
995
+ def finish_extraction(action, change_set, touched, started)
996
+ result = publish(action, change_set, touched, started)
997
+ if action == :full
998
+ @startup_full = false
999
+ @pending_mutex.synchronize { @startup_paths.clear }
1000
+ end
1001
+ result
1002
+ end
1003
+
899
1004
  def actionable_count(change_set)
900
1005
  change_set.relative_paths.count { |path| @policy.classify(path) != :ignore }
901
1006
  end
@@ -992,8 +1097,10 @@ module Woods
992
1097
  return false if ENV['WOODS_IGNORE_WATCH'] == '1'
993
1098
  return false unless @status.alive?
994
1099
 
995
- other = @status.read['pid']
996
- return false if other.nil? || other.to_i == Process.pid
1100
+ record = @status.read
1101
+ other = record['pid']
1102
+ return false if other.nil?
1103
+ return false if same_claim_host?(record['host']) && other.to_i == Process.pid
997
1104
 
998
1105
  @logger.warn(
999
1106
  "[Woods] a watch daemon (pid #{other}) is already maintaining #{@output_dir} — standing down. " \
@@ -1225,7 +1332,8 @@ module Woods
1225
1332
 
1226
1333
  def build_watcher
1227
1334
  @watcher = Watcher.build(
1228
- root: @root, ignored: ignored_directories, logger: @logger, force_polling: @force_polling
1335
+ root: @root, ignored: ignored_directories, logger: @logger, force_polling: @force_polling,
1336
+ poll_interval: @poll_interval
1229
1337
  )
1230
1338
  end
1231
1339
 
@@ -15,6 +15,9 @@ module Woods
15
15
  # silent while files change under it. Hosts in that position should force
16
16
  # polling rather than trust this backend.
17
17
  class ListenWatcher
18
+ # Optional daemon handshake; called once detection is established.
19
+ attr_writer :ready_callback
20
+
18
21
  # @param root [String, Pathname] directory to watch
19
22
  # @param ignored [Array<String>] directory names/prefixes to skip
20
23
  # @param listen_class [Class] injectable for specs
@@ -50,6 +53,7 @@ module Woods
50
53
  on_change.call(batch) if batch.any?
51
54
  end
52
55
  @listener.start
56
+ @ready_callback&.call
53
57
  rescue StandardError => e
54
58
  # inotify watch exhaustion (ENOSPC) lands here, and it is the most
55
59
  # likely failure on a large tree. {Watcher.build}'s caller falls back
@@ -18,11 +18,14 @@ module Woods
18
18
  # is what keeps that bounded: skipping `.git`, `node_modules`, `tmp` and
19
19
  # friends takes a Rails app from "everything" to "the source tree".
20
20
  class PollingWatcher
21
+ # Optional daemon handshake; called once detection is established.
22
+ attr_writer :ready_callback
23
+
21
24
  # @param root [String, Pathname] directory to watch
22
25
  # @param ignored [Array<String>] directory names/prefixes to skip
23
26
  # @param interval [Float] seconds between scans
24
27
  # @param sleeper [#call] injected for specs, so they need not pass time
25
- def initialize(root:, ignored: Watcher::DEFAULT_IGNORED_DIRECTORIES, interval: 1.0,
28
+ def initialize(root:, ignored: Watcher::DEFAULT_IGNORED_DIRECTORIES, interval: Watcher::DEFAULT_POLL_INTERVAL,
26
29
  sleeper: ->(seconds) { sleep(seconds) })
27
30
  @root = root.to_s
28
31
  @ignored = ignored
@@ -47,6 +50,7 @@ module Woods
47
50
 
48
51
  @running = true
49
52
  primed_now?
53
+ @ready_callback&.call
50
54
 
51
55
  while @running
52
56
  @sleeper.call(@interval)
@@ -31,9 +31,10 @@ module Woods
31
31
  STATES = %i[running degraded stopped].freeze
32
32
 
33
33
  # A status record older than this is not believed, however healthy it
34
- # claims to be. Generous relative to a debounce window, because an idle
35
- # daemon only rewrites its status when something happens.
34
+ # claims to be. The daemon heartbeats every five minutes, including
35
+ # while idle, so this allows two missed heartbeats.
36
36
  STALE_AFTER = 900 # 15 minutes
37
+ MAX_FUTURE_SKEW = 30 # seconds of clock difference tolerated across hosts
37
38
 
38
39
  # @param output_dir [String, Pathname] index directory
39
40
  # @param clock [#call] returns the ISO8601 stamp for a write
@@ -86,31 +87,34 @@ module Woods
86
87
 
87
88
  # Is a daemon currently maintaining this index?
88
89
  #
89
- # Three things have to hold, and each rules out a different way the
90
- # status file lies: the state has to be one a live daemon writes, the
91
- # recorded pid has to still exist (a `kill -9` leaves the file behind),
92
- # and the record has to be recent (a machine that lost power leaves a
93
- # `running` record with a pid some unrelated process now owns).
90
+ # Require a live state, a positive pid, and a recent timestamp. A local
91
+ # pid must still exist; foreign-host trust explicitly substitutes bounded
92
+ # heartbeat freshness for that uncheckable process evidence. A crashed
93
+ # foreign daemon can therefore remain believable until the record ages out.
94
94
  #
95
95
  # Callers use this to decide whether to do the work themselves — a
96
96
  # session-start hook that would otherwise run `woods:incremental` can
97
97
  # skip it when a daemon is already on the job.
98
98
  #
99
99
  # @param max_age [Numeric] seconds after which a record is disbelieved
100
+ # @param trust_foreign_host [Boolean] trust a fresh foreign-host record
101
+ # without a local pid check; defaults to WOODS_WATCH_TRUST_FOREIGN_HOST=1
100
102
  # @return [Boolean]
101
- def alive?(max_age: STALE_AFTER)
103
+ def alive?(max_age: STALE_AFTER, trust_foreign_host: ENV['WOODS_WATCH_TRUST_FOREIGN_HOST'] == '1')
102
104
  record = read
103
105
  return false unless %w[running degraded].include?(record['state'])
104
- return false unless same_host?(record['host'])
105
- return false unless process_alive?(record['pid'])
106
+ return false unless record['pid'].is_a?(Integer) && record['pid'].positive?
107
+ return false unless recent?(record['updated_at'], max_age)
108
+ return trust_foreign_host unless same_host?(record['host'])
106
109
 
107
- recent?(record['updated_at'], max_age)
110
+ process_alive?(record['pid'])
108
111
  end
109
112
 
110
113
  # The identity a pid is only meaningful within.
111
114
  #
112
115
  # In a container the hostname defaults to the container id, so this
113
- # changes exactly when the pid namespace does.
116
+ # usually changes with the pid namespace. Custom or reused hostnames
117
+ # cannot establish namespace identity.
114
118
  #
115
119
  # @return [String]
116
120
  def self.host_identity
@@ -128,8 +132,8 @@ module Woods
128
132
  # deployment. A container pid like 47 almost always exists on the host, so
129
133
  # a host hook would read `running` plus a live-looking pid plus a fresh
130
134
  # timestamp and stand down while nothing was covering it. Comparing the
131
- # recorded host means a cross-namespace reader disbelieves the record
132
- # rather than misreading it.
135
+ # recorded host keeps a cross-namespace reader from checking an unrelated
136
+ # local pid. Foreign records are rejected unless freshness trust is enabled.
133
137
  #
134
138
  # Records written before this field existed have no host; treat them as
135
139
  # same-host so an in-place upgrade does not declare a live daemon dead.
@@ -160,7 +164,8 @@ module Woods
160
164
  def recent?(iso8601, max_age)
161
165
  return false if iso8601.nil?
162
166
 
163
- Time.parse(@clock.call) - Time.parse(iso8601) <= max_age
167
+ age = Time.iso8601(@clock.call) - Time.iso8601(iso8601)
168
+ age.between?(-MAX_FUTURE_SKEW, max_age)
164
169
  rescue ArgumentError, TypeError
165
170
  false
166
171
  end
@@ -50,14 +50,14 @@ module Woods
50
50
  def each_file(root:, ignored:, visited: nil, &block)
51
51
  base = root.to_s
52
52
  prefix = "#{base}/"
53
- visited ||= Set.new
53
+ visited = (visited || Set.new).dup
54
54
  visited << real_dir(base)
55
55
 
56
56
  Find.find(base) do |path|
57
57
  next if path == base
58
58
 
59
59
  skip = skip?(path.delete_prefix(prefix), ignored)
60
- Find.prune if visit_entry(path, skip, ignored, visited, &block) == :prune
60
+ Find.prune if visit_entry(path, skip, ignored, visited, root: base, &block) == :prune
61
61
  end
62
62
  rescue Errno::ENOENT
63
63
  # The root vanished mid-walk (a worktree removed under us). Nothing to
@@ -68,7 +68,7 @@ module Woods
68
68
  # Classify one entry and act on it.
69
69
  #
70
70
  # @return [Symbol, nil] `:prune` when the caller should not descend
71
- def visit_entry(path, skip, ignored, visited, &block)
71
+ def visit_entry(path, skip, ignored, visited, root:, &block)
72
72
  # `Find` stats with `lstat`, so it neither descends a symlinked
73
73
  # directory nor reports one as a file — the entry would just vanish. A
74
74
  # full extraction globs, and `Dir.glob` *does* follow them, so leaving
@@ -76,7 +76,7 @@ module Woods
76
76
  # symlinked `app/models`, or a monorepo linking shared code in, simply
77
77
  # never produced an event.
78
78
  if symlinked_directory?(path)
79
- descend_symlink(path, ignored, visited, &block) unless skip
79
+ descend_symlink(path, ignored, visited | directory_ancestors(path, root), &block) unless skip
80
80
  nil
81
81
  elsif File.directory?(path)
82
82
  skip ? :prune : nil
@@ -86,19 +86,27 @@ module Woods
86
86
  end
87
87
  end
88
88
 
89
- # Walk a symlinked directory, once.
90
- #
91
- # The `visited` set is keyed on the *resolved* path, which is what makes
92
- # this terminate: a link pointing at its own ancestor is an infinite tree
93
- # to any walker that follows links naively, and two links pointing at one
94
- # directory would otherwise yield every file under it twice — enough to
95
- # make a change look like two changes to the caller diffing walks.
89
+ # Detect cycles along this logical traversal branch, not across siblings.
90
+ # Two aliases of one target are distinct extraction paths: suppressing
91
+ # one can leave only an ignored alias and hide the relevant app path.
92
+ # Find does not expose exit events for ordinary directories, so collect
93
+ # their ancestors at each symlink rather than retaining a global set.
94
+ def directory_ancestors(path, root)
95
+ ancestors = Set.new
96
+ directory = File.dirname(path)
97
+ loop do
98
+ ancestors << real_dir(directory)
99
+ break if directory == root || File.dirname(directory) == directory
100
+
101
+ directory = File.dirname(directory)
102
+ end
103
+ ancestors
104
+ end
105
+
96
106
  def descend_symlink(path, ignored, visited, &block)
97
107
  target = real_dir(path)
98
108
  return if target.nil? || visited.include?(target)
99
109
 
100
- visited << target
101
-
102
110
  # Walk the *resolved* directory — `Find.find` lstats even the root it is
103
111
  # handed, so pointing it at the link itself yields the link and stops —
104
112
  # then rewrite each result back under the link. Callers compare these
@@ -27,6 +27,9 @@ module Woods
27
27
  # Every backend implements the same two methods: `start(&on_change)`,
28
28
  # which yields an array of absolute paths, and `stop`.
29
29
  module Watcher
30
+ # Seconds of sleep between polling scans.
31
+ DEFAULT_POLL_INTERVAL = 1.0
32
+
30
33
  # Directory names never worth watching. Scanning them is what makes a
31
34
  # polling watcher expensive, and a change inside one is never extraction
32
35
  # input.
@@ -52,7 +55,7 @@ module Woods
52
55
  # @param force_polling [Boolean] skip the listen backend entirely
53
56
  # @param logger [#info, #warn] where backend selection is reported
54
57
  # @return [#start, #stop]
55
- def build(root:, ignored: DEFAULT_IGNORED_DIRECTORIES, poll_interval: 1.0,
58
+ def build(root:, ignored: DEFAULT_IGNORED_DIRECTORIES, poll_interval: DEFAULT_POLL_INTERVAL,
56
59
  force_polling: false, logger: nil)
57
60
  force_polling ||= containerized?
58
61