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
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Watch
5
+ # Native filesystem events via the `listen` gem.
6
+ #
7
+ # Preferred when available: no idle CPU and sub-second latency. Woods does
8
+ # not depend on `listen`, so {Watcher.build} only reaches for this backend
9
+ # when the host already has it (Rails apps usually do — it's what
10
+ # `ActiveSupport::EventedFileUpdateChecker` uses).
11
+ #
12
+ # Note the caveat that motivates keeping {PollingWatcher} around: native
13
+ # events do not propagate reliably across container bind mounts, so a
14
+ # daemon inside a dev container watching a mounted host directory can sit
15
+ # silent while files change under it. Hosts in that position should force
16
+ # polling rather than trust this backend.
17
+ class ListenWatcher
18
+ # @param root [String, Pathname] directory to watch
19
+ # @param ignored [Array<String>] directory names/prefixes to skip
20
+ # @param listen_class [Class] injectable for specs
21
+ def initialize(root:, ignored: Watcher::DEFAULT_IGNORED_DIRECTORIES, listen_class: nil)
22
+ @root = root.to_s
23
+ @ignored = ignored
24
+ @listen_class = listen_class || ::Listen
25
+ # Set here rather than in #sleep_until_stopped: a `stop` arriving during
26
+ # startup — the daemon's signal handler racing its watcher thread, which
27
+ # they do by construction — used to be overwritten by the parking loop's
28
+ # own initialization, leaving a watcher nothing could stop.
29
+ @stopped = false
30
+ end
31
+
32
+ # Begin delivering events until {#stop}.
33
+ #
34
+ # `listen` reports modified, added and removed paths as three separate
35
+ # arrays; the daemon treats them uniformly (a vanished path is resolved
36
+ # by the extraction layer, which keys deletion on the file being gone),
37
+ # so they are merged into one batch.
38
+ #
39
+ # @yieldparam changed [Array<String>] absolute paths
40
+ # @return [void]
41
+ def start(&on_change)
42
+ # Only the setup is wrapped. `sleep_until_stopped` parks for the whole
43
+ # life of the daemon, so folding it into this rescue labelled every
44
+ # later failure — including one raised by `on_change`, i.e. by the
45
+ # extraction itself — as "failed to start", and sent it up as a
46
+ # WatcherError the daemon then treated as a dead backend.
47
+ begin
48
+ @listener = @listen_class.to(@root, ignore: ignore_patterns) do |modified, added, removed|
49
+ batch = (modified + added + removed).uniq
50
+ on_change.call(batch) if batch.any?
51
+ end
52
+ @listener.start
53
+ rescue StandardError => e
54
+ # inotify watch exhaustion (ENOSPC) lands here, and it is the most
55
+ # likely failure on a large tree. {Watcher.build}'s caller falls back
56
+ # to polling on this, which works where listen just gave up.
57
+ raise WatcherError, "listen backend failed to start: #{e.class}: #{e.message}"
58
+ end
59
+
60
+ sleep_until_stopped
61
+ end
62
+
63
+ # @return [void]
64
+ def stop
65
+ @stopped = true
66
+ @listener&.stop
67
+ end
68
+
69
+ private
70
+
71
+ def ignore_patterns
72
+ @ignored.map { |dir| %r{\A#{Regexp.escape(dir)}(/|\z)} }
73
+ end
74
+
75
+ # `listen` runs its own thread, so the caller's thread has to park.
76
+ def sleep_until_stopped
77
+ sleep(0.2) until @stopped
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ require_relative 'tree_scan'
6
+
7
+ module Woods
8
+ module Watch
9
+ # Detects changes by comparing a directory tree's mtimes between scans.
10
+ #
11
+ # The dependency-free backend, and the one that works where native FS
12
+ # events don't — most importantly across container bind mounts, which is
13
+ # the common Woods deployment. It reports creations, modifications and
14
+ # deletions alike, because it diffs two snapshots rather than listening
15
+ # for individual events.
16
+ #
17
+ # Cost is one `File.mtime` per watched file per interval. The ignore list
18
+ # is what keeps that bounded: skipping `.git`, `node_modules`, `tmp` and
19
+ # friends takes a Rails app from "everything" to "the source tree".
20
+ class PollingWatcher
21
+ # @param root [String, Pathname] directory to watch
22
+ # @param ignored [Array<String>] directory names/prefixes to skip
23
+ # @param interval [Float] seconds between scans
24
+ # @param sleeper [#call] injected for specs, so they need not pass time
25
+ def initialize(root:, ignored: Watcher::DEFAULT_IGNORED_DIRECTORIES, interval: 1.0,
26
+ sleeper: ->(seconds) { sleep(seconds) })
27
+ @root = root.to_s
28
+ @ignored = ignored
29
+ @interval = interval
30
+ @sleeper = sleeper
31
+ @running = false
32
+ @stop_requested = false
33
+ @snapshot = nil
34
+ end
35
+
36
+ # Scan until {#stop}, yielding each batch of changed absolute paths.
37
+ #
38
+ # A {#stop} that arrives before or during startup is honoured rather than
39
+ # overwritten: the daemon's signal handler and its watcher thread race by
40
+ # construction, and a `SIGINT` during boot used to be swallowed, leaving a
41
+ # watcher nobody could stop.
42
+ #
43
+ # @yieldparam changed [Array<String>] absolute paths
44
+ # @return [void]
45
+ def start(&on_change)
46
+ return if @stop_requested
47
+
48
+ @running = true
49
+ primed_now?
50
+
51
+ while @running
52
+ @sleeper.call(@interval)
53
+ break unless @running
54
+
55
+ changed = poll
56
+ on_change.call(changed) if changed.any?
57
+ end
58
+ end
59
+
60
+ # Stop after the current interval.
61
+ #
62
+ # @return [void]
63
+ def stop
64
+ @stop_requested = true
65
+ @running = false
66
+ end
67
+
68
+ # Take the baseline the next {#poll} compares against, if it hasn't been
69
+ # taken already.
70
+ #
71
+ # Separate from {#poll} so that a watcher's first cycle reports nothing:
72
+ # announcing every file in the tree on startup would trip the daemon's
73
+ # storm threshold on every boot.
74
+ #
75
+ # @return [Boolean] true when this call took the baseline
76
+ def primed_now?
77
+ return false unless @snapshot.nil?
78
+
79
+ @snapshot = scan
80
+ true
81
+ end
82
+
83
+ # Run one comparison and return what moved. Public so a caller can drive
84
+ # the watcher synchronously — which is exactly what the specs do, and
85
+ # what an embedded host that owns its own loop would do.
86
+ #
87
+ # The first call primes the baseline and reports nothing.
88
+ #
89
+ # @return [Array<String>] changed absolute paths
90
+ def poll
91
+ return [] if primed_now?
92
+
93
+ previous = @snapshot
94
+ @snapshot = scan
95
+
96
+ added = @snapshot.keys - previous.keys
97
+ removed = previous.keys - @snapshot.keys
98
+ modified = (@snapshot.keys & previous.keys).reject { |path| @snapshot[path] == previous[path] }
99
+
100
+ (added + removed + modified).sort
101
+ end
102
+
103
+ private
104
+
105
+ # Full-resolution mtime *and* size, because whole-second mtimes lose real
106
+ # modifications. Truncating to `to_i` means a write at T+0.1s recorded by
107
+ # a poll at T+0.5s and a second write at T+0.9s produce the same stamp:
108
+ # the next poll sees no change and no later event ever mentions the file
109
+ # again. Save-then-formatter inside one second is entirely ordinary at the
110
+ # default 1s interval, and ext4/APFS/XFS all carry sub-second mtimes.
111
+ #
112
+ # Size is the tiebreaker for the residual case — a filesystem that really
113
+ # only offers 1s granularity (some network and container mounts), where a
114
+ # same-second rewrite that changes length is still caught.
115
+ #
116
+ # @return [Hash{String => Array}] path => [mtime, size], per watched file
117
+ def scan
118
+ snapshot = {}
119
+
120
+ each_watched_path do |path|
121
+ stat = File.stat(path)
122
+ snapshot[path] = [stat.mtime.to_f, stat.size]
123
+ rescue SystemCallError
124
+ # Vanished between the glob and the stat — the next scan sees it as
125
+ # removed, which is the same answer one interval later.
126
+ next
127
+ end
128
+
129
+ snapshot
130
+ end
131
+
132
+ def each_watched_path(&block)
133
+ TreeScan.each_file(root: @root, ignored: @ignored, &block)
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require 'socket'
6
+
7
+ require_relative '../atomic_file'
8
+
9
+ module Woods
10
+ module Watch
11
+ # The daemon's own liveness and health, written where a reader can find it.
12
+ #
13
+ # A stale index is only dangerous when nothing says so. An agent asking
14
+ # `woods_status` needs to distinguish three situations that look identical
15
+ # from the index alone:
16
+ #
17
+ # * **running** — the index is current, or will be within a debounce window.
18
+ # * **degraded** — the daemon is alive but *cannot* update: a syntax error
19
+ # mid-edit made the reload fail, the watcher died, extraction raised. The
20
+ # index is intact and frozen at a known generation; the reason says why.
21
+ # * **stopped** — nothing is maintaining this index. Fall back to whatever
22
+ # the last explicit run left.
23
+ #
24
+ # Degraded is the important one. #164's failure posture is that a daemon
25
+ # never crash-loops and never publishes a partial write, which means it
26
+ # spends real time in a state where the answer it can give is out of date.
27
+ # Saying so is the difference between a stale answer and a wrong one.
28
+ class Status
29
+ FILENAME = 'watch_status.json'
30
+
31
+ STATES = %i[running degraded stopped].freeze
32
+
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.
36
+ STALE_AFTER = 900 # 15 minutes
37
+
38
+ # @param output_dir [String, Pathname] index directory
39
+ # @param clock [#call] returns the ISO8601 stamp for a write
40
+ def initialize(output_dir:, clock: -> { Time.now.utc.iso8601 })
41
+ @path = File.join(output_dir.to_s, FILENAME)
42
+ @clock = clock
43
+ end
44
+
45
+ # @return [String] absolute path to the status file
46
+ attr_reader :path
47
+
48
+ # Record the daemon's current state.
49
+ #
50
+ # @param state [Symbol] one of {STATES}
51
+ # @param generation [Integer, nil] the generation the index is at
52
+ # @param reason [String, nil] required in spirit for `:degraded`
53
+ # @param details [Hash] extra fields (pid, last batch size, timings)
54
+ # @return [Hash] the record as written
55
+ def write(state:, generation: nil, reason: nil, **details)
56
+ raise ArgumentError, "Unknown watch state #{state.inspect}" unless STATES.include?(state)
57
+
58
+ record = {
59
+ 'state' => state.to_s,
60
+ 'reason' => reason,
61
+ 'generation' => generation,
62
+ 'pid' => Process.pid,
63
+ 'host' => self.class.host_identity,
64
+ 'updated_at' => @clock.call
65
+ }.merge(details.transform_keys(&:to_s))
66
+
67
+ # 0644 on purpose (O1): host-side worktree hooks read this file
68
+ # through a bind mount (the documented deployment, see Daemon's
69
+ # daemon-deference check), making it the one artifact with a
70
+ # cross-boundary consumer. Everything else Woods writes keeps
71
+ # AtomicFile's restrictive 0600 default.
72
+ AtomicFile.write(@path, JSON.generate(record), mode: 0o644)
73
+ record
74
+ end
75
+
76
+ # The last recorded state, or a stopped record when there is none.
77
+ #
78
+ # @return [Hash] string-keyed status record
79
+ def read
80
+ return { 'state' => 'stopped', 'reason' => 'no daemon has run' } unless File.exist?(@path)
81
+
82
+ JSON.parse(AtomicFile.read(@path))
83
+ rescue JSON::ParserError, SystemCallError, EncodingError => e
84
+ { 'state' => 'stopped', 'reason' => "unreadable status file: #{e.message}" }
85
+ end
86
+
87
+ # Is a daemon currently maintaining this index?
88
+ #
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).
94
+ #
95
+ # Callers use this to decide whether to do the work themselves — a
96
+ # session-start hook that would otherwise run `woods:incremental` can
97
+ # skip it when a daemon is already on the job.
98
+ #
99
+ # @param max_age [Numeric] seconds after which a record is disbelieved
100
+ # @return [Boolean]
101
+ def alive?(max_age: STALE_AFTER)
102
+ record = read
103
+ 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
+
107
+ recent?(record['updated_at'], max_age)
108
+ end
109
+
110
+ # The identity a pid is only meaningful within.
111
+ #
112
+ # In a container the hostname defaults to the container id, so this
113
+ # changes exactly when the pid namespace does.
114
+ #
115
+ # @return [String]
116
+ def self.host_identity
117
+ @host_identity ||= Socket.gethostname
118
+ rescue StandardError
119
+ 'unknown'
120
+ end
121
+
122
+ private
123
+
124
+ # A pid is only meaningful inside the namespace that issued it.
125
+ #
126
+ # The daemon runs in a dev container while the status file is read by
127
+ # host-side worktree hooks through a bind mount — the documented
128
+ # deployment. A container pid like 47 almost always exists on the host, so
129
+ # a host hook would read `running` plus a live-looking pid plus a fresh
130
+ # 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.
133
+ #
134
+ # Records written before this field existed have no host; treat them as
135
+ # same-host so an in-place upgrade does not declare a live daemon dead.
136
+ def same_host?(host)
137
+ host.nil? || host == self.class.host_identity
138
+ end
139
+
140
+ # Signal 0 asks "could I signal this process?" without sending anything.
141
+ # EPERM means it exists but belongs to someone else — still alive.
142
+ def process_alive?(pid)
143
+ return false unless pid.is_a?(Integer) && pid.positive?
144
+
145
+ Process.kill(0, pid)
146
+ true
147
+ rescue Errno::ESRCH
148
+ false
149
+ rescue Errno::EPERM
150
+ true
151
+ end
152
+
153
+ # Both sides of this comparison must come from the same clock.
154
+ #
155
+ # `@clock` is injected so a spec can drive staleness without sleeping for
156
+ # a quarter of an hour; reading the left-hand side from `Time.now`
157
+ # regardless made that injection a no-op for the one thing it exists to
158
+ # test — a stubbed clock could write an `updated_at` far in the past or
159
+ # future and `recent?` would still answer from the wall clock.
160
+ def recent?(iso8601, max_age)
161
+ return false if iso8601.nil?
162
+
163
+ Time.parse(@clock.call) - Time.parse(iso8601) <= max_age
164
+ rescue ArgumentError, TypeError
165
+ false
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'find'
4
+ require 'set'
5
+
6
+ module Woods
7
+ module Watch
8
+ # Walks a watched tree once, applying the ignore list.
9
+ #
10
+ # Extracted because two callers need exactly the same notion of "the files
11
+ # under this root that could be extraction input": {PollingWatcher}, which
12
+ # diffs two walks, and {Daemon}'s startup catch-up, which compares one walk
13
+ # against the index's watermark. Keeping them on one implementation means
14
+ # the daemon can never catch up over a different file set than the watcher
15
+ # subsequently watches.
16
+ #
17
+ # `Find.find` with `Find.prune`, not `Dir.glob`, and the difference is
18
+ # operational rather than stylistic. A glob enumerates the whole tree and
19
+ # filters afterwards, so it descends into `.git` and `node_modules` and
20
+ # stats everything inside them before discarding the results. On a monolith
21
+ # across a virtiofs or gRPC-FUSE bind mount — the exact deployment polling
22
+ # exists to serve — that alone can take longer than the poll interval,
23
+ # producing sustained IO and change latency measured in tens of seconds.
24
+ # Pruning means an ignored subtree is never entered at all.
25
+ #
26
+ # `Find` also takes the root as a plain path, which sidesteps the other
27
+ # trap: a root containing glob metacharacters (`[`, `{`, `*` — all legal in
28
+ # a directory name, and `[` shows up in generated worktree paths) makes an
29
+ # interpolated glob pattern match nothing, silently watching an empty tree.
30
+ module TreeScan
31
+ # Files that look like source but are editor or VCS bookkeeping. Dotfiles
32
+ # are skipped wholesale except where Woods genuinely reads one.
33
+ NOT_IGNORED_DOTFILES = ['.ruby-version'].freeze
34
+
35
+ # Dotfile *prefixes* Woods still has to see. `.env`, `.env.local`,
36
+ # `.env.development` and friends are boot-captured configuration
37
+ # ({ReloadPolicy} classifies them `:restart`), so filtering them out here
38
+ # would make that classification unreachable — the daemon would never
39
+ # learn the file changed at all.
40
+ NOT_IGNORED_DOTFILE_PREFIXES = ['.env'].freeze
41
+
42
+ module_function
43
+
44
+ # Yield every watched file under a root as an absolute path.
45
+ #
46
+ # @param root [String, Pathname] directory to walk
47
+ # @param ignored [Array<String>] directory names/prefixes to skip
48
+ # @yieldparam path [String] absolute path to a regular file
49
+ # @return [void]
50
+ def each_file(root:, ignored:, visited: nil, &block)
51
+ base = root.to_s
52
+ prefix = "#{base}/"
53
+ visited ||= Set.new
54
+ visited << real_dir(base)
55
+
56
+ Find.find(base) do |path|
57
+ next if path == base
58
+
59
+ skip = skip?(path.delete_prefix(prefix), ignored)
60
+ Find.prune if visit_entry(path, skip, ignored, visited, &block) == :prune
61
+ end
62
+ rescue Errno::ENOENT
63
+ # The root vanished mid-walk (a worktree removed under us). Nothing to
64
+ # report; the caller's next cycle sees it gone.
65
+ nil
66
+ end
67
+
68
+ # Classify one entry and act on it.
69
+ #
70
+ # @return [Symbol, nil] `:prune` when the caller should not descend
71
+ def visit_entry(path, skip, ignored, visited, &block)
72
+ # `Find` stats with `lstat`, so it neither descends a symlinked
73
+ # directory nor reports one as a file — the entry would just vanish. A
74
+ # full extraction globs, and `Dir.glob` *does* follow them, so leaving
75
+ # this alone meant the daemon was blind to a tree the oracle indexes: a
76
+ # symlinked `app/models`, or a monorepo linking shared code in, simply
77
+ # never produced an event.
78
+ if symlinked_directory?(path)
79
+ descend_symlink(path, ignored, visited, &block) unless skip
80
+ nil
81
+ elsif File.directory?(path)
82
+ skip ? :prune : nil
83
+ else
84
+ block.call(path) unless skip
85
+ nil
86
+ end
87
+ end
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.
96
+ def descend_symlink(path, ignored, visited, &block)
97
+ target = real_dir(path)
98
+ return if target.nil? || visited.include?(target)
99
+
100
+ visited << target
101
+
102
+ # Walk the *resolved* directory — `Find.find` lstats even the root it is
103
+ # handed, so pointing it at the link itself yields the link and stops —
104
+ # then rewrite each result back under the link. Callers compare these
105
+ # paths against change sets and the graph's registered paths, so they
106
+ # have to read as the tree looks, not as it resolves.
107
+ each_file(root: target, ignored: ignored, visited: visited) do |file|
108
+ block.call(File.join(path, file.delete_prefix("#{target}/")))
109
+ end
110
+ end
111
+
112
+ # @return [Boolean] true for a symlink that resolves to a directory
113
+ def symlinked_directory?(path)
114
+ File.symlink?(path) && File.directory?(path)
115
+ rescue SystemCallError
116
+ false
117
+ end
118
+
119
+ # @return [String, nil] resolved path, or nil for a broken/looping link
120
+ def real_dir(path)
121
+ File.realpath(path)
122
+ rescue SystemCallError
123
+ nil
124
+ end
125
+
126
+ # @return [Boolean] whether this entry is neither watched nor worth
127
+ # descending into
128
+ def skip?(relative, ignored)
129
+ hidden?(relative) || ignored?(relative, ignored)
130
+ end
131
+
132
+ # @param root [String, Pathname] directory to walk
133
+ # @param ignored [Array<String>] directory names/prefixes to skip
134
+ # @return [Array<String>] absolute paths of every watched file
135
+ def files(root:, ignored:)
136
+ [].tap { |paths| each_file(root: root, ignored: ignored) { |path| paths << path } }
137
+ end
138
+
139
+ # @param relative [String] path relative to the watched root
140
+ # @return [Boolean] whether any segment is a dotfile Woods does not read
141
+ def hidden?(relative)
142
+ relative.split(File::SEPARATOR).any? { |segment| hidden_segment?(segment) }
143
+ end
144
+
145
+ # @param segment [String] one path component
146
+ # @return [Boolean]
147
+ def hidden_segment?(segment)
148
+ return false unless segment.start_with?('.')
149
+ return false if ['.', '..'].include?(segment)
150
+ return false if NOT_IGNORED_DOTFILES.include?(segment)
151
+
152
+ NOT_IGNORED_DOTFILE_PREFIXES.none? { |prefix| segment.start_with?(prefix) }
153
+ end
154
+
155
+ # @param relative [String] path relative to the watched root
156
+ # @param ignored [Array<String>] directory names/prefixes to skip
157
+ # @return [Boolean]
158
+ def ignored?(relative, ignored)
159
+ ignored.any? { |dir| relative == dir || relative.start_with?("#{dir}/") }
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'woods'
4
+
5
+ module Woods
6
+ module Watch
7
+ # Raised when a watcher backend cannot be started.
8
+ class WatcherError < Woods::Error; end
9
+
10
+ # File-change sources for the daemon.
11
+ #
12
+ # Two backends, and the choice is operational rather than aesthetic:
13
+ #
14
+ # * {ListenWatcher} wraps the `listen` gem, which uses native FS events
15
+ # (inotify, FSEvents) — low latency, no idle CPU. Requires the gem,
16
+ # which Woods does not depend on.
17
+ # * {PollingWatcher} scans mtimes on an interval. Slower and does real
18
+ # work while idle, but it needs nothing and, critically, it *works
19
+ # across container bind mounts* — where native events are famously
20
+ # unreliable (documented by `listen` itself; macOS Docker VMs are the
21
+ # usual casualty). Extraction typically runs inside a dev container with
22
+ # the source bind-mounted, so this is not an edge case.
23
+ #
24
+ # {.build} prefers listen and falls back, because a daemon that silently
25
+ # never fires is worse than one that costs a little CPU.
26
+ #
27
+ # Every backend implements the same two methods: `start(&on_change)`,
28
+ # which yields an array of absolute paths, and `stop`.
29
+ module Watcher
30
+ # Directory names never worth watching. Scanning them is what makes a
31
+ # polling watcher expensive, and a change inside one is never extraction
32
+ # input.
33
+ DEFAULT_IGNORED_DIRECTORIES = %w[
34
+ .git
35
+ node_modules
36
+ tmp
37
+ log
38
+ coverage
39
+ vendor/bundle
40
+ public/assets
41
+ public/packs
42
+ storage
43
+ ].freeze
44
+
45
+ module_function
46
+
47
+ # Build the best available backend for a root.
48
+ #
49
+ # @param root [String, Pathname] directory to watch
50
+ # @param ignored [Array<String>] directory names/prefixes to skip
51
+ # @param poll_interval [Float] seconds between scans (polling backend)
52
+ # @param force_polling [Boolean] skip the listen backend entirely
53
+ # @param logger [#info, #warn] where backend selection is reported
54
+ # @return [#start, #stop]
55
+ def build(root:, ignored: DEFAULT_IGNORED_DIRECTORIES, poll_interval: 1.0,
56
+ force_polling: false, logger: nil)
57
+ force_polling ||= containerized?
58
+
59
+ unless force_polling
60
+ begin
61
+ require 'listen'
62
+ logger&.info('[Woods] watch: using native FS events (listen)')
63
+ return ListenWatcher.new(root: root, ignored: ignored)
64
+ rescue LoadError
65
+ logger&.info('[Woods] watch: listen gem not available, polling instead')
66
+ end
67
+ end
68
+
69
+ logger&.info("[Woods] watch: polling every #{poll_interval}s")
70
+ PollingWatcher.new(root: root, ignored: ignored, interval: poll_interval)
71
+ end
72
+
73
+ # Are we probably inside a container?
74
+ #
75
+ # The failure this guards is silent and total: on a runtime where FS
76
+ # events do not cross the bind mount, auto-preferring `listen` produces a
77
+ # daemon that reports `running`, never fires, and keeps the index frozen
78
+ # while claiming to maintain it. Polling on a runtime that would have
79
+ # propagated events costs some CPU. Those are not symmetric, so the
80
+ # ambiguous case defaults to the recoverable one.
81
+ #
82
+ # Deliberately a heuristic and deliberately overridable — event
83
+ # propagation is runtime-specific (OrbStack propagates; Docker Desktop's
84
+ # VM is the case `listen` itself documents as unreliable), so a host that
85
+ # knows better sets `WOODS_WATCH_POLL=0`.
86
+ #
87
+ # @return [Boolean]
88
+ def containerized?
89
+ return ENV['WOODS_WATCH_POLL'] != '0' if ENV.key?('WOODS_WATCH_POLL')
90
+
91
+ File.exist?('/.dockerenv') || File.exist?('/run/.containerenv')
92
+ rescue StandardError
93
+ false
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ require_relative 'polling_watcher'
100
+ require_relative 'listen_watcher'