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,1345 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../change_set'
4
+ require_relative '../dependency_graph'
5
+ require_relative '../coordination/pipeline_lock'
6
+ require_relative '../atomic_file'
7
+ require_relative '../generation'
8
+ require_relative '../reload_policy'
9
+ require_relative 'status'
10
+ require_relative 'tree_scan'
11
+ require_relative 'watcher'
12
+ require 'json'
13
+ require 'set'
14
+ require 'securerandom'
15
+
16
+ module Woods
17
+ module Watch
18
+ # A resident, booted-app process that keeps the index current as files
19
+ # change.
20
+ #
21
+ # Woods' differentiating data — reflection-true associations, resolved
22
+ # callback chains, inlined concerns — requires a booted Rails app, which
23
+ # is why freshness has been pull-based: every sync from a cold process
24
+ # pays a full boot, so syncing gets batched at hook points instead of
25
+ # happening continuously. The boot requirement doesn't actually force
26
+ # batch semantics, though. A process that stays booted and re-extracts on
27
+ # file events gives runtime-true answers with seconds of lag. This is that
28
+ # process.
29
+ #
30
+ # One cycle:
31
+ #
32
+ # watch → debounce → classify → reload if needed → extract → publish
33
+ #
34
+ # ## Classification decides the cycle, not the file list
35
+ #
36
+ # {ReloadPolicy} answers "what has to happen before re-extracting is
37
+ # truthful", and the daemon obeys it rather than re-extracting blindly:
38
+ #
39
+ # * `:ignore` — nothing to do; no publish, no generation bump.
40
+ # * `:reextract` — Woods reads bytes. Extract directly.
41
+ # * `:reload` — an autoloaded constant changed. Reload first, or the
42
+ # extractors introspect classes that no longer match their source.
43
+ # * `:restart` — boot-captured state changed (initializers, `config/**`,
44
+ # `Gemfile.lock`, schema). Rails' reloader re-runs none of it, so the
45
+ # daemon stops and asks to be restarted rather than serving answers
46
+ # derived from a boot that no longer describes the app. This is
47
+ # Spring's contract, copied deliberately: Spring's staleness bugs came
48
+ # from under-scoping exactly this set.
49
+ #
50
+ # ## Failure posture
51
+ #
52
+ # A syntax error mid-edit is normal, not exceptional — it happens every
53
+ # time someone saves halfway through a thought. So a failed reload is not
54
+ # a crash: the daemon publishes a degraded {Status} naming the reason,
55
+ # leaves the index intact at its last good generation, and tries again on
56
+ # the next event. Nothing advances a cursor over work that didn't land,
57
+ # and the generation is bumped only after a successful write, so a reader
58
+ # can always tell "current" from "frozen, and here's why".
59
+ #
60
+ # ## Storms
61
+ #
62
+ # A branch switch or rebase touches hundreds of files at once. Above
63
+ # `full_extraction_threshold`, N incremental steps cost more than one full
64
+ # extraction and risk interleaving with a still-settling tree, so the
65
+ # daemon falls back to a full run and logs that it did.
66
+ #
67
+ # ## Startup is not a clean slate
68
+ #
69
+ # A daemon that only reacts to events it personally witnessed is stale the
70
+ # moment it starts: edits and pulled commits that landed while nothing was
71
+ # watching are invisible to it forever. That matters because callers stand
72
+ # down when a daemon is alive ({Status#alive?}), so "a daemon is running"
73
+ # has to mean "these changes are covered". {#run} therefore reconciles
74
+ # against the index's own watermark before waiting for its first event.
75
+ #
76
+ # ## Placement
77
+ #
78
+ # Every collaborator is injected, so this class doesn't care whether it
79
+ # runs as a dedicated process (`rake woods:watch`), inside an existing
80
+ # dev-server process, or driven a batch at a time by a host that owns its
81
+ # own loop. {#process} is the whole cycle for one batch and is safe to
82
+ # call directly — which is how the specs drive it, and how an embedded
83
+ # host would. It also drains anything a previous cycle carried forward, so
84
+ # an embedded caller gets the same retry behaviour {#run} does.
85
+ #
86
+ # @example A dedicated daemon
87
+ # Woods::Watch::Daemon.new(output_dir: Rails.root.join("tmp/woods")).run
88
+ #
89
+ # rubocop:disable-next Metrics/ClassLength
90
+ class Daemon
91
+ # Seconds of quiet before a batch is considered settled. An editor
92
+ # save, a formatter rewriting the file, and a linter touching it again
93
+ # should be one cycle, not three.
94
+ DEFAULT_DEBOUNCE = 0.4
95
+
96
+ # Changed-file count above which a full extraction is cheaper and safer
97
+ # than iterating.
98
+ DEFAULT_FULL_EXTRACTION_THRESHOLD = 50
99
+
100
+ # Name of the file lock that serializes writers within one worktree.
101
+ # Worktrees are disjoint by construction — each has its own Rails.root
102
+ # and its own output dir — so this only ever contends with another
103
+ # writer against the *same* index: a manual `woods:extract`, or a hook
104
+ # sync that fired anyway.
105
+ LOCK_NAME = 'extraction'
106
+
107
+ # Name of the O_EXCL claim file that closes the startup TOCTOU: two
108
+ # daemons that both pass {#another_daemon_alive?} before either has
109
+ # published a status record would otherwise both proceed. See
110
+ # {#claim_startup?}.
111
+ CLAIM_FILENAME = 'watch_claim.json'
112
+
113
+ # A daemon cycle is milliseconds; a manual full extraction is seconds to
114
+ # minutes. This bounds how long a crashed writer can block the daemon.
115
+ LOCK_STALE_TIMEOUT = 600
116
+
117
+ # How often an otherwise idle daemon re-stamps its status file.
118
+ #
119
+ # {Status#alive?} disbelieves a record older than {Status::STALE_AFTER},
120
+ # and cycle boundaries are the only other thing that writes one — so
121
+ # without a heartbeat a *healthy* daemon reads as dead after a quiet
122
+ # quarter-hour, which is the single most common state for a worktree
123
+ # nobody is typing in. Callers would then stop standing down and start
124
+ # contending for its lock. A third of the window leaves room for two
125
+ # missed beats.
126
+ HEARTBEAT_INTERVAL = Status::STALE_AFTER / 3.0
127
+
128
+ # How long shutdown waits for the heartbeat thread to notice
129
+ # @stop_reason and exit on its own before falling back to Thread#kill.
130
+ #
131
+ # This is not the correctness backstop — #process's own `ensure`
132
+ # re-merges a batch the heartbeat had drained regardless of whether it
133
+ # exits cooperatively or gets killed, so a short window is enough. Most
134
+ # of the time the heartbeat is parked in its own `sleep(heartbeat_tick)`
135
+ # (up to HEARTBEAT_INTERVAL, minutes, when idle_timeout is unset) and
136
+ # will not wake up to see @stop_reason inside any bounded wait — this
137
+ # only pays off when it happens to already be near the end of a cycle,
138
+ # which most daemon cycles are (a single-file cycle is milliseconds).
139
+ HEARTBEAT_SHUTDOWN_TIMEOUT = 2
140
+
141
+ # @return [Woods::Generation]
142
+ attr_reader :generation
143
+
144
+ # @return [Woods::Watch::Status]
145
+ attr_reader :status
146
+
147
+ # @param output_dir [String, Pathname] index directory
148
+ # @param root [String, Pathname] application root (defaults to Rails.root)
149
+ # @param extractor_factory [#call] returns a fresh {Woods::Extractor}
150
+ # @param reloader [#reload!, #enabled?] Rails reload adapter
151
+ # @param watcher [#start, #stop, nil] built from config when nil
152
+ # @param policy [Woods::ReloadPolicy]
153
+ # @param debounce [Float] quiet window in seconds
154
+ # @param full_extraction_threshold [Integer]
155
+ # @param idle_timeout [Numeric, nil] stop after this many idle seconds
156
+ # @param lock [Woods::Coordination::PipelineLock, nil] writer lock for
157
+ # this index; built from {LOCK_NAME} when nil
158
+ # @param catch_up [Boolean] reconcile changes that predate startup
159
+ # @param force_polling [Boolean] never use the `listen` backend — the
160
+ # right choice across a container bind mount, where native FS events
161
+ # do not propagate
162
+ # @param logger [#info, #warn, #error]
163
+ # rubocop:disable-next Metrics/ParameterLists -- every collaborator is
164
+ # injectable on purpose; that is what makes the daemon placement-agnostic
165
+ # and drivable from a spec without Rails.
166
+ def initialize(output_dir:, root: nil, extractor_factory: nil, reloader: nil, watcher: nil,
167
+ policy: ReloadPolicy.new, debounce: DEFAULT_DEBOUNCE,
168
+ full_extraction_threshold: DEFAULT_FULL_EXTRACTION_THRESHOLD,
169
+ idle_timeout: nil, lock: nil, catch_up: true, force_polling: false, logger: nil)
170
+ @output_dir = output_dir.to_s
171
+ @root = (root || (defined?(Rails) ? Rails.root : Dir.pwd)).to_s
172
+ @extractor_factory = extractor_factory || -> { Woods::Extractor.new(output_dir: @output_dir) }
173
+ @reloader = reloader || RailsReloader.new
174
+ @watcher = watcher
175
+ @policy = policy
176
+ @debounce = debounce
177
+ @full_extraction_threshold = full_extraction_threshold
178
+ @idle_timeout = idle_timeout
179
+ @catch_up = catch_up
180
+ @force_polling = force_polling
181
+ @logger = logger || default_logger
182
+ @generation = Generation.new(output_dir: @output_dir)
183
+ @status = Status.new(output_dir: @output_dir)
184
+ @lock = lock || default_lock
185
+ reset_cycle_state
186
+ end
187
+
188
+ # Watch until stopped, a restart is required, or the idle timeout fires.
189
+ #
190
+ # @return [Symbol] why the loop ended — `:stopped`, `:restart_required`,
191
+ # `:idle`, or `:already_running` when another daemon already covers this
192
+ # index
193
+ def run
194
+ # Standing down must leave no trace. The shutdown writes live in
195
+ # {#run_started}'s `ensure`, which Ruby runs on an early `return` too —
196
+ # so hanging them off *this* method had the daemon that correctly
197
+ # refused to start persist an empty pending file over the live daemon's
198
+ # carried paths and publish `stopped` under its own pid over the live
199
+ # `running` record. Until the live daemon's next heartbeat re-stamped
200
+ # the truth (up to {HEARTBEAT_INTERVAL}), `woods:watch_status` read
201
+ # dead — a `watch_status || start` hook booted a third daemon and
202
+ # `woods:incremental` stopped standing down.
203
+ return :already_running if another_daemon_alive?
204
+
205
+ # {#another_daemon_alive?} is a plain status READ — cheap, and gives
206
+ # the useful log message above, but proves nothing under a race: two
207
+ # daemons starting together can both read "nothing running yet" and
208
+ # both pass. #claim_startup? is the atomic gate that actually decides
209
+ # who gets to proceed.
210
+ return :already_running unless claim_startup?
211
+
212
+ run_started
213
+ end
214
+
215
+ # Stop the loop at the next opportunity.
216
+ #
217
+ # @return [void]
218
+ def stop
219
+ @stop_reason = :stopped
220
+ @watcher&.stop
221
+ end
222
+
223
+ # Run one full cycle for a batch of changed paths.
224
+ #
225
+ # This is the daemon's whole behaviour; {#run} only supplies batches.
226
+ # Calling it directly is the supported way to embed the daemon in a
227
+ # process that owns its own event loop.
228
+ #
229
+ # @param paths [Array<String>] changed paths, absolute or root-relative.
230
+ # Anything a previous cycle carried forward is folded in.
231
+ # @return [Hash] `{ action:, state:, generation:, reason:, count:,
232
+ # duration_ms: }`
233
+ def process(paths = [])
234
+ change_set = ChangeSet.new(paths: drain_with(paths), root: @root)
235
+ cycle_completed = false
236
+
237
+ begin
238
+ result = case required_action(change_set)
239
+ when :ignore then nothing_to_do
240
+ when :restart then require_restart(change_set)
241
+ when :reload then attempt_reload ? extract(change_set) : degraded_reload(change_set)
242
+ else extract(change_set)
243
+ end
244
+ cycle_completed = true
245
+ result
246
+ ensure
247
+ # Thread#kill bypasses rescue, but not ensure — a thread killed
248
+ # anywhere in this dispatch (mid-extraction, most commonly, when a
249
+ # shutdown's heartbeat.kill lands on a thread doing real work) would
250
+ # otherwise lose the batch #drain_with already popped from @pending.
251
+ # Every branch above that finishes normally already carries its own
252
+ # paths forward on failure (or intentionally doesn't, e.g. :restart);
253
+ # this only fires for the abnormal case none of them can catch.
254
+ carry_forward(change_set) unless cycle_completed
255
+ end
256
+ end
257
+
258
+ private
259
+
260
+ # The watch loop proper, split from {#run} so the shutdown `ensure` —
261
+ # publish `stopped`, persist carried paths — can only ever fire for a
262
+ # daemon that actually started. A stand-down returns from {#run} without
263
+ # entering this method, so nothing it does on the way out can clobber
264
+ # the live daemon's records.
265
+ #
266
+ # The watcher starts on its own thread BEFORE {#catch_up} runs, not
267
+ # after — see {#launch_watcher}. {#catch_up} still runs to completion
268
+ # on this thread; only once it returns do we park alongside the
269
+ # watcher, waiting for its next event or a stop.
270
+ def run_started
271
+ @watcher ||= build_watcher
272
+ publish_status(:running, reason: nil)
273
+ @last_event_at = monotonic_now
274
+ heartbeat = start_heartbeat
275
+
276
+ watcher_thread = launch_watcher do |paths|
277
+ enqueue(paths)
278
+ drain
279
+ end
280
+ catch_up
281
+ watcher_thread.join
282
+ raise @watcher_failure if @watcher_failure
283
+
284
+ @stop_reason || :stopped
285
+ ensure
286
+ shut_down(heartbeat, watcher_thread)
287
+ end
288
+
289
+ # @param heartbeat [Thread, nil]
290
+ # @param watcher_thread [Thread, nil]
291
+ # @return [void]
292
+ def shut_down(heartbeat, watcher_thread)
293
+ # The watch loop can end without anything having set a reason — a
294
+ # `stop` that landed before the watcher noticed it, for instance. Set
295
+ # one before touching the heartbeat: its loop's own exit check is
296
+ # `break if @stop_reason`, so a heartbeat that is actually awake right
297
+ # now (as opposed to parked in its long idle sleep) needs this to see
298
+ # the run is ending at all. Doesn't affect {#run_started}'s return
299
+ # value — that already evaluated before this `ensure` ran.
300
+ @stop_reason ||= :stopped
301
+ # Cooperative first: ask the watcher to stop and let its thread notice
302
+ # and exit on its own. #kill bypasses rescue (not ensure — #process
303
+ # guards the batch it drains either way), so it is the last resort,
304
+ # not the first move.
305
+ @watcher&.stop
306
+ # A no-op when {#run_started}'s body already joined normally; this
307
+ # only does real work when something raised (from #catch_up, most
308
+ # plausibly) before reaching that line, and it must still happen
309
+ # before #persist_pending — otherwise a watcher thread still draining
310
+ # a batch races the shutdown snapshot of @pending.
311
+ watcher_thread&.join
312
+ stop_heartbeat(heartbeat)
313
+ # Before #persist_pending, for the same reason the watcher join is: a
314
+ # retry drain still running would race the shutdown snapshot of
315
+ # @pending.
316
+ stop_retry(@retry_thread)
317
+ persist_pending
318
+ publish_status(:stopped, reason: @stop_reason&.to_s)
319
+ release_claim
320
+ end
321
+
322
+ # Start the watcher backend on its own thread, ahead of {#catch_up}.
323
+ #
324
+ # A file saved while catch_up's reconciliation (potentially a
325
+ # minutes-long, storm-triggered full extraction) was running used to be
326
+ # lost twice over: no watcher existed yet to report it as an event, and
327
+ # {PollingWatcher} takes its baseline snapshot inside `start` — after
328
+ # the save, so the first diff already excludes it. Worse, the save's
329
+ # mtime predates the generation bump catch_up's own extraction publishes
330
+ # at the end, so a future restart's watermark check reads the file as
331
+ # already covered — permanently. Starting the watcher first closes that
332
+ # window: {#enqueue} and {#drain} already tolerate the duplicate paths
333
+ # this produces against whatever catch_up finds on its own via the tree
334
+ # scan.
335
+ #
336
+ # Exceptions are captured here rather than left to `Thread`'s own
337
+ # unhandled-exception handling, so {#run_started} can re-raise the
338
+ # *same* error exactly once — after giving catch_up its turn — instead
339
+ # of `Thread#join` re-raising it again on every subsequent join,
340
+ # including the defensive one in this method's `ensure`.
341
+ #
342
+ # @yieldparam paths [Array<String>] absolute paths from one watcher batch
343
+ # @return [Thread]
344
+ def launch_watcher(&on_change)
345
+ Thread.new do
346
+ start_watching(&on_change)
347
+ rescue StandardError => e
348
+ @watcher_failure = e
349
+ end
350
+ end
351
+
352
+ # @param heartbeat [Thread, nil]
353
+ # @return [void]
354
+ def stop_heartbeat(heartbeat)
355
+ return unless heartbeat
356
+
357
+ heartbeat.join(HEARTBEAT_SHUTDOWN_TIMEOUT) || heartbeat.kill
358
+ end
359
+
360
+ # What this batch demands, with one escalation applied: an app that cannot
361
+ # reload at all (`config.enable_reloading = false` — the production
362
+ # default, and common in staging-shaped dev containers) can only honour a
363
+ # `:reload` by restarting, since extracting against constants that no
364
+ # longer match their source is the thing the classification exists to
365
+ # prevent.
366
+ def required_action(change_set)
367
+ action = @policy.classify_all(change_set.relative_paths)
368
+ return :restart if action == :reload && !@reloader.enabled?
369
+
370
+ action
371
+ end
372
+
373
+ # An all-ignorable batch is not evidence that a previously degraded
374
+ # condition cleared — nothing was retried, so nothing was proven. Flipping
375
+ # back to `running` here would advertise a healthy index while the reload
376
+ # that failed is still failing.
377
+ def nothing_to_do
378
+ outcome(:ignore, @degraded_reason ? :degraded : :running, reason: @degraded_reason)
379
+ end
380
+
381
+ def default_lock
382
+ Coordination::PipelineLock.new(
383
+ lock_dir: @output_dir, name: LOCK_NAME, stale_timeout: LOCK_STALE_TIMEOUT
384
+ )
385
+ end
386
+
387
+ def reset_cycle_state
388
+ @pending = Set.new
389
+ @pending_mutex = Mutex.new
390
+ @stop_reason = nil
391
+ @drain_mutex = Mutex.new
392
+ @retry_thread = nil
393
+ end
394
+
395
+ # Wait out the debounce window so events that land during it join the
396
+ # same cycle.
397
+ #
398
+ # This only coalesces because the watcher callback merges into `@pending`
399
+ # and returns immediately ({#enqueue}) rather than processing inline —
400
+ # so a save, the formatter's rewrite, and the linter's touch accumulate
401
+ # here and {#process} drains all three as one batch. Sleeping alone would
402
+ # just delay the first of three cycles.
403
+ def settle
404
+ sleep(@debounce) if @debounce.to_f.positive?
405
+ end
406
+
407
+ # Merge a watcher batch into the pending set without processing it.
408
+ def enqueue(paths)
409
+ absolute = ChangeSet.new(paths: paths, root: @root).absolute_paths
410
+ @pending_mutex.synchronize { @pending.merge(absolute) }
411
+ @last_event_at = monotonic_now
412
+ end
413
+
414
+ # Fold in anything a previous cycle could not process.
415
+ #
416
+ # A cycle skipped for lock contention, a failed reload, or a raising
417
+ # extraction must not lose its paths — the files really did change, and
418
+ # no later event will mention them again. They ride along with the next
419
+ # batch instead.
420
+ def drain_with(paths)
421
+ @pending_mutex.synchronize do
422
+ carried = @pending.to_a
423
+ @pending = Set.new
424
+ carried.empty? ? Array(paths) : (carried + Array(paths)).uniq
425
+ end
426
+ end
427
+
428
+ def carry_forward(change_set)
429
+ @pending_mutex.synchronize { @pending.merge(change_set.absolute_paths) }
430
+ end
431
+
432
+ # Run cycles until the pending set is empty. One call per watcher batch;
433
+ # re-entrant calls return immediately so listen's thread pool cannot run
434
+ # two cycles against one index.
435
+ #
436
+ # `try_lock`, not a boolean: check-then-set on an ivar is exactly the
437
+ # race it is guarding against — two callback threads could both read
438
+ # `false` before either wrote `true` and run two overlapping drains. The
439
+ # loser's paths are already in `@pending` (the callback enqueues before
440
+ # calling here), so the winner's loop picks them up; nothing is lost by
441
+ # returning. `try_lock` also returns false on same-thread re-entry, so a
442
+ # synchronous callback fired from inside a cycle cannot deadlock.
443
+ def drain
444
+ return unless @drain_mutex.try_lock
445
+
446
+ begin
447
+ drain_cycles
448
+ ensure
449
+ # An extraction is work, not idleness. Stamping only on the event
450
+ # would let a cycle longer than `idle_timeout` read as a quiet
451
+ # daemon and stop the watcher mid-run.
452
+ @last_event_at = monotonic_now
453
+ @drain_mutex.unlock
454
+ end
455
+ end
456
+
457
+ def drain_cycles
458
+ until pending_empty? || @stop_reason
459
+ settle
460
+ result = process
461
+
462
+ if result[:action] == :restart
463
+ # @watcher, not a captured local — start_watching's polling
464
+ # fallback can have reassigned it earlier in this same run
465
+ # (daemon.rb's start_watching), and stopping the discarded
466
+ # pre-fallback watcher leaves the one actually running untouched.
467
+ @watcher&.stop
468
+ break
469
+ end
470
+ # A degraded cycle deliberately carried its paths forward. Retrying
471
+ # them in a tight loop would spin on a failure that needs an edit to
472
+ # clear, so wait for the next event.
473
+ break if result[:state] == :degraded
474
+ end
475
+ end
476
+
477
+ def pending_empty?
478
+ @pending_mutex.synchronize { @pending.empty? }
479
+ end
480
+
481
+ # Carry the pending set across a restart.
482
+ #
483
+ # Within a run, carried paths survive; at shutdown they were dropped, and
484
+ # recovery fell to the mtime watermark — which does not cover them. If
485
+ # another writer bumps the generation *after* the daemon carried a path
486
+ # forward, the watermark is newer than the file's mtime and catch-up skips
487
+ # it: the change is lost for good while the status says `running`.
488
+ # Sequence: save `user.rb`; a hook sync holds the lock; the daemon's cycle
489
+ # contends and carries it; the hook finishes and bumps; the daemon stops.
490
+ def persist_pending
491
+ paths = @pending_mutex.synchronize { @pending.to_a }
492
+ return AtomicFile.write(pending_path, JSON.generate([])) if paths.empty?
493
+
494
+ @logger.info("[Woods] watch: persisting #{paths.size} unindexed path(s) for the next run")
495
+ AtomicFile.write(pending_path, JSON.generate(paths))
496
+ rescue StandardError => e
497
+ @logger.warn("[Woods] watch: could not persist pending paths — #{e.message}")
498
+ end
499
+
500
+ # Paths a previous run owed, reloaded at startup so catch-up covers them
501
+ # regardless of what the watermark says.
502
+ def restore_pending
503
+ return [] unless File.exist?(pending_path)
504
+
505
+ paths = JSON.parse(AtomicFile.read(pending_path))
506
+ return [] unless paths.is_a?(Array) && paths.any?
507
+
508
+ @logger.info("[Woods] watch: #{paths.size} path(s) carried over from the previous run")
509
+ paths.grep(String)
510
+ rescue StandardError
511
+ []
512
+ end
513
+
514
+ def pending_path
515
+ File.join(@output_dir, 'watch_pending.json')
516
+ end
517
+
518
+ # Reconcile changes that predate this daemon.
519
+ #
520
+ # The generation file is rewritten as the last act of every successful
521
+ # extraction, so its mtime is "when this index was last known good".
522
+ # Anything modified since is uncovered, whoever made the change and
523
+ # whether or not a daemon was watching at the time. With no generation
524
+ # file at all there is no index, and every file is uncovered — which the
525
+ # storm threshold correctly turns into one full extraction.
526
+ #
527
+ # @return [void]
528
+ def catch_up
529
+ return unless @catch_up
530
+
531
+ carried = restore_pending
532
+ paths = (uncovered_paths + carried).uniq
533
+ if paths.empty?
534
+ return reconcile_deletions if stale_deletions?
535
+
536
+ return @logger.info('[Woods] watch: index is current at startup')
537
+ end
538
+
539
+ @logger.info("[Woods] watch: #{paths.size} path(s) changed before startup — catching up")
540
+ enqueue(paths)
541
+ drain
542
+ end
543
+
544
+ # A file deleted while nothing was watching leaves no mtime for the scan
545
+ # to see — {TreeScan} only walks files that exist — so a deletion-only
546
+ # downtime would log "index is current" while ghost units survive. The
547
+ # graph knows every path it attributed a unit to; any of those gone from
548
+ # disk means the extractor's sweep has reconciling to do.
549
+ #
550
+ # The daemon only *detects*; it does not name the paths. Naming them
551
+ # would put them in the change set, whose deletions are authoritative for
552
+ # any unit type — and some registered paths are nominal (on Rails < 7.1,
553
+ # `ActiveRecord::SchemaMigration` registers a convention path no app
554
+ # has), so authoritative deletion would remove units a full extraction
555
+ # still produces. An empty-change-set run reaches the same ghosts through
556
+ # the sweep, which carries the bounds that make it safe.
557
+ def stale_deletions?
558
+ root_prefix = "#{@root}/"
559
+
560
+ persisted_registered_paths.any? do |path|
561
+ path.start_with?(root_prefix) && !File.exist?(path)
562
+ end
563
+ end
564
+
565
+ # The graph persists **relative** paths (#166) so the index is portable off
566
+ # the machine that wrote it. They have to be absolutized here, because the
567
+ # caller tests them with `File.exist?` and bounds the check with
568
+ # `start_with?(root_prefix)` — against relative paths that guard excludes
569
+ # every entry, which would silently disable deletion reconciliation
570
+ # entirely rather than fail visibly.
571
+ #
572
+ # A path that is already absolute passes through, which covers a graph
573
+ # written before #166 as well as genuinely out-of-tree paths (gem sources)
574
+ # that the caller's prefix check is there to exclude.
575
+ def persisted_registered_paths
576
+ payload = Woods::Generation.new(output_dir: @output_dir).payload_dir
577
+ graph = File.join(payload.to_s, 'dependency_graph.json')
578
+ file_map = JSON.parse(AtomicFile.read(graph))['file_map']
579
+ return [] unless file_map.is_a?(Hash)
580
+
581
+ file_map.keys.map { |path| Woods::DependencyGraph.absolutize(path, @root) }
582
+ rescue SystemCallError, JSON::ParserError
583
+ []
584
+ end
585
+
586
+ def reconcile_deletions
587
+ @logger.info('[Woods] watch: registered file(s) vanished before startup — reconciling deletions')
588
+ extract(ChangeSet.new(paths: [], root: @root))
589
+ end
590
+
591
+ def uncovered_paths
592
+ watermark = index_watermark
593
+ TreeScan.files(root: @root, ignored: ignored_directories)
594
+ .select { |path| uncovered?(path, watermark) }
595
+ end
596
+
597
+ def uncovered?(path, watermark)
598
+ return true if watermark.nil?
599
+
600
+ File.mtime(path).to_f > watermark
601
+ rescue SystemCallError
602
+ false
603
+ end
604
+
605
+ # When this index was last known good, or nil when there is no usable
606
+ # index — which the storm threshold turns into one full extraction.
607
+ #
608
+ # A dangling payload pointer counts as "no index" (INF-10). The marker
609
+ # can outlive the directory it names (a partial restore from a CI
610
+ # artifact, an external cleanup targeting the large directories), and
611
+ # {Generation#payload_dir} deliberately degrades to the index root for
612
+ # *readers* — so a gutted index would otherwise read "current at
613
+ # startup", publish `running`, and have every caller stand down over a
614
+ # directory holding no index at all. "Alive means covered" is the
615
+ # daemon's contract; here it would have been false.
616
+ #
617
+ # @return [Float, nil]
618
+ def index_watermark
619
+ return nil if dangling_payload_pointer?
620
+
621
+ File.mtime(@generation.path).to_f
622
+ rescue SystemCallError
623
+ nil
624
+ end
625
+
626
+ # @return [Boolean] true when the marker names a payload directory that
627
+ # no longer resolves
628
+ def dangling_payload_pointer?
629
+ marker = @generation.current
630
+ name = marker.payload
631
+ return false if name.nil? || name.empty?
632
+
633
+ @generation.payload_dir(marker) == @generation.root
634
+ rescue ScriptError, StandardError
635
+ false
636
+ end
637
+
638
+ # Keep the status file believable, and stop a dormant daemon.
639
+ #
640
+ # Two jobs, one timer. The heartbeat exists because {Status#alive?}
641
+ # disbelieves an old record (see {HEARTBEAT_INTERVAL}); the idle stop
642
+ # exists because N worktrees means N booted apps and most are dormant
643
+ # most of the time — a slot nobody is working in should not hold ~65 MB
644
+ # waiting to be needed, and a worktree hook or session start revives it.
645
+ # Idle stopping is off by default; the heartbeat is not.
646
+ #
647
+ # @return [Thread]
648
+ def start_heartbeat
649
+ Thread.new do
650
+ loop do
651
+ sleep(heartbeat_tick)
652
+ break if @stop_reason
653
+
654
+ if idle_expired?
655
+ @logger.info("[Woods] watch: idle for #{@idle_timeout}s — exiting")
656
+ @stop_reason = :idle
657
+ # @watcher, not a captured local — see the same note in
658
+ # #drain_cycles. Idle-stop is exactly the path that missed this:
659
+ # a daemon that fell back to polling and then went idle never
660
+ # actually stopped, because it stopped the discarded watcher.
661
+ @watcher&.stop
662
+ break
663
+ end
664
+
665
+ restamp_status
666
+ # A storm-triggered full extraction can outlive LOCK_STALE_TIMEOUT,
667
+ # at which point a contender would retire the lock of a run that is
668
+ # still going. The holder has to keep saying it is alive.
669
+ @lock.touch if @lock.respond_to?(:touch)
670
+ retry_pending
671
+ end
672
+ end
673
+ end
674
+
675
+ # Retry work a degraded cycle carried forward, without waiting for a new
676
+ # file event.
677
+ #
678
+ # `drain_cycles` stops on a degraded result on purpose — retrying in a
679
+ # tight loop would spin on a cause that needs an edit to clear. But then
680
+ # only a *new* event starts another drain, so paths carried past a
681
+ # contending writer sit unindexed for as long as the developer happens to
682
+ # stop typing. The heartbeat is already the right cadence for "try that
683
+ # again": slow enough not to spin, frequent enough that a finished
684
+ # contender is noticed in minutes rather than never.
685
+ #
686
+ # The drain runs on its **own** thread, not this one (INF-2). Running it
687
+ # inline meant the heartbeat stopped restamping the status and stopped
688
+ # touching the lock for the whole duration of the retried extraction —
689
+ # and a retried storm (`extract_all` on a large host) can outlive
690
+ # {LOCK_STALE_TIMEOUT}, at which point any waiting writer retires the
691
+ # live lock and two writers clobber one index. {#drain}'s `try_lock`
692
+ # already refuses overlapping drains; the `alive?` check keeps
693
+ # consecutive heartbeat ticks from stacking threads behind it.
694
+ def retry_pending
695
+ return if pending_empty? || @stop_reason
696
+ return if @retry_thread&.alive?
697
+
698
+ @logger.info('[Woods] watch: retrying paths carried forward from an earlier cycle')
699
+ @retry_thread = start_retry_thread
700
+ end
701
+
702
+ # @return [Thread] a thread running one drain
703
+ def start_retry_thread
704
+ thread = Thread.new do
705
+ drain
706
+ rescue ScriptError, StandardError => e
707
+ # #run_extraction rescues its own failures, so reaching here means
708
+ # something outside it broke. Log rather than let `join` re-raise it
709
+ # into the shutdown path.
710
+ @logger.error("[Woods] watch: retry cycle failed — #{e.class}: #{e.message}")
711
+ end
712
+ thread.report_on_exception = false
713
+ thread
714
+ end
715
+
716
+ # Wind down a retry drain at shutdown.
717
+ #
718
+ # Bounded exactly like {#stop_heartbeat}: before INF-2 the retry ran on
719
+ # the heartbeat thread and inherited that thread's join-then-kill, so
720
+ # this preserves the shutdown timing rather than making a long retry
721
+ # hold the process open. {#shut_down} sets +@stop_reason+ first, so
722
+ # {#drain_cycles} ends after the cycle already in flight.
723
+ #
724
+ # @param thread [Thread, nil]
725
+ # @return [void]
726
+ def stop_retry(thread)
727
+ return unless thread
728
+
729
+ thread.join(HEARTBEAT_SHUTDOWN_TIMEOUT) || thread.kill
730
+ end
731
+
732
+ def heartbeat_tick
733
+ ticks = [HEARTBEAT_INTERVAL]
734
+ ticks << (@idle_timeout / 4.0) if @idle_timeout.to_f.positive?
735
+ [ticks.min, 0.05].max
736
+ end
737
+
738
+ def idle_expired?
739
+ return false unless @idle_timeout.to_f.positive?
740
+ return false if @drain_mutex.locked?
741
+
742
+ monotonic_now - @last_event_at >= @idle_timeout
743
+ end
744
+
745
+ # Rewrite the last published record so its timestamp stays fresh. Note it
746
+ # republishes the *last* state rather than `:running` — a degraded daemon
747
+ # is still degraded between events, and saying otherwise is the one thing
748
+ # the status file exists to prevent.
749
+ def restamp_status
750
+ record = @last_status
751
+ return if record.nil?
752
+
753
+ @status.write(**record)
754
+ rescue StandardError => e
755
+ @logger.warn("[Woods] watch: could not refresh status — #{e.message}")
756
+ end
757
+
758
+ # Start the watcher, falling back to polling if the native backend cannot
759
+ # start at all (inotify exhaustion being the usual reason). A daemon that
760
+ # costs some CPU beats one that silently never fires.
761
+ def start_watching(&on_change)
762
+ @watcher.start(&on_change)
763
+ rescue WatcherError => e
764
+ raise if @polling_fallback
765
+
766
+ @polling_fallback = true
767
+ @logger.warn("[Woods] watch: #{e.message} — falling back to polling")
768
+ # `ignored:` must travel with the rebuild (INF-1). Dropping it re-arms
769
+ # the output-directory feedback loop {#ignored_directories} exists to
770
+ # break, on precisely the path a large tree reaches.
771
+ @watcher = Watcher.build(
772
+ root: @root, ignored: ignored_directories, logger: @logger, force_polling: true
773
+ )
774
+ @watcher.start(&on_change)
775
+ end
776
+
777
+ def attempt_reload
778
+ @reload_error = nil
779
+ @reloader.reload!
780
+ true
781
+ rescue ScriptError, StandardError => e
782
+ # A syntax error mid-edit lands here and is completely routine. Note
783
+ # ScriptError: SyntaxError is not a StandardError, so rescuing only
784
+ # StandardError would let a half-typed file kill the daemon.
785
+ @reload_error = "#{e.class}: #{e.message}"
786
+ @logger.warn("[Woods] watch: reload failed — #{@reload_error}")
787
+ false
788
+ end
789
+
790
+ # The reload failed, so the constants no longer match their source and
791
+ # extracting now would publish introspection of a stale class graph.
792
+ #
793
+ # The paths still have to survive. A developer who saves a valid
794
+ # `post.rb` while `user.rb` sits half-typed gets one app-wide reload
795
+ # failure covering both; when `user.rb` is fixed, that event names only
796
+ # `user.rb`, and `post.rb`'s change would never reach the index at all.
797
+ def degraded_reload(change_set)
798
+ carry_forward(change_set)
799
+ outcome(:reload, :degraded, reason: @reload_error, count: change_set.size)
800
+ end
801
+
802
+ def require_restart(change_set)
803
+ triggers = @policy.paths_requiring(change_set.relative_paths, :restart)
804
+ reason = "restart required: #{triggers.first(5).join(', ')}"
805
+ @logger.warn("[Woods] watch: #{reason}")
806
+ @stop_reason = :restart_required
807
+ outcome(:restart, :degraded, reason: reason, count: change_set.size)
808
+ end
809
+
810
+ def extract(change_set)
811
+ started = monotonic_now
812
+ acquired = acquire_lock_for(change_set, started)
813
+ return acquired if acquired.is_a?(Hash) # a raising acquire already built its own degraded outcome
814
+ return contended(change_set, started) unless acquired
815
+
816
+ begin
817
+ run_extraction(change_set, started)
818
+ ensure
819
+ # Best-effort: {#run_extraction} already decided the outcome (which
820
+ # this `ensure` must not touch), so a release that raises here is
821
+ # logged, not escalated. It used to share the method-level rescue
822
+ # below with the acquire — which mislabeled a release failure after
823
+ # a *successful* run as "could not take the extraction lock" and,
824
+ # because an exception raised in `ensure` replaces whatever the
825
+ # `begin` block was about to return, silently discarded that good
826
+ # outcome along with it.
827
+ release_lock_quietly
828
+ end
829
+ end
830
+
831
+ # Acquiring is itself IO and can fail (a read-only or full output dir).
832
+ # Scoped to just the acquire call so a failure here is never confused
833
+ # with a failure in the extraction or the release that follows it.
834
+ #
835
+ # @return [Boolean, Hash] the acquire result, or a ready-made degraded
836
+ # {#outcome} when acquiring itself raised
837
+ def acquire_lock_for(change_set, started)
838
+ @lock.acquire
839
+ rescue ScriptError, StandardError => e
840
+ carry_forward(change_set)
841
+ reason = "could not take the extraction lock: #{e.class}: #{e.message}"
842
+ @logger.error("[Woods] watch: #{reason}")
843
+ outcome(:extract, :degraded, reason: reason, count: change_set.size,
844
+ duration_ms: elapsed_ms(started))
845
+ end
846
+
847
+ # @return [void]
848
+ def release_lock_quietly
849
+ @lock.release
850
+ rescue ScriptError, StandardError => e
851
+ @logger.warn("[Woods] watch: could not release the extraction lock — #{e.message}")
852
+ end
853
+
854
+ # Another writer holds the extraction lock — a manual `woods:extract`,
855
+ # or a hook sync that fired anyway. Yield rather than race: the manual
856
+ # run is doing the same job, and the daemon's paths are carried into the
857
+ # next cycle so nothing is lost.
858
+ def contended(change_set, started)
859
+ carry_forward(change_set)
860
+ reason = 'another writer holds the extraction lock — retrying on the next event'
861
+ @logger.info("[Woods] watch: #{reason}")
862
+ outcome(:contended, :degraded, reason: reason, count: change_set.size,
863
+ duration_ms: elapsed_ms(started))
864
+ end
865
+
866
+ 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
+
874
+ before = @generation.current.number
875
+ extractor = @extractor_factory.call
876
+ touched = if full
877
+ extractor.extract_all
878
+ :all
879
+ else
880
+ extractor.extract_changed(change_set.absolute_paths)
881
+ end
882
+
883
+ action = full ? :full : :incremental
884
+ return unpublished(action, change_set, started) if wrote_without_publishing?(touched, before)
885
+
886
+ publish(action, change_set, touched, started)
887
+ rescue ScriptError, StandardError => e
888
+ # Extraction failed, so nothing landed — the generation stays where it
889
+ # was and the index keeps serving its last good state.
890
+ reason = "extraction failed: #{e.class}: #{e.message}"
891
+ @logger.error("[Woods] watch: #{reason}")
892
+ # The paths really did change; a later event will not mention them
893
+ # again, so carry them forward and try once the cause clears.
894
+ carry_forward(change_set)
895
+ outcome(:extract, :degraded, reason: reason, count: change_set.size,
896
+ duration_ms: elapsed_ms(started))
897
+ end
898
+
899
+ def actionable_count(change_set)
900
+ change_set.relative_paths.count { |path| @policy.classify(path) != :ignore }
901
+ end
902
+
903
+ # Did the extractor write units without the generation moving?
904
+ #
905
+ # `Extractor#publish_generation` rescues its own failures on purpose — an
906
+ # index that landed correctly should not be thrown away because the
907
+ # marker could not be written. But the generation *is* the freshness
908
+ # contract: readers self-refresh on it, `woods_status` reports it, and a
909
+ # cycle that silently fails to bump leaves every reader serving the
910
+ # previous index while the daemon says `running`. Not raising was right;
911
+ # not noticing was not.
912
+ #
913
+ # A no-op incremental deliberately does not bump, so this only fires when
914
+ # units were actually written.
915
+ def wrote_without_publishing?(touched, before)
916
+ return false if touched != :all && Array(touched).empty?
917
+
918
+ @generation.current.number == before
919
+ end
920
+
921
+ # The units are on disk and correct; only the marker that advertises them
922
+ # is missing. Degraded rather than failed, and the paths ride forward so
923
+ # the next successful cycle republishes them.
924
+ def unpublished(action, change_set, started)
925
+ carry_forward(change_set)
926
+ reason = 'index written but the generation did not advance — readers will not see it'
927
+ @logger.error("[Woods] watch: #{reason}")
928
+ outcome(action, :degraded, reason: reason, count: change_set.size,
929
+ duration_ms: elapsed_ms(started))
930
+ end
931
+
932
+ def publish(action, change_set, touched, started)
933
+ # The extractor bumps the generation as the last write of a successful
934
+ # run, so the daemon reads the number rather than minting a second one
935
+ # — two bumps per cycle would make the counter lie about how many
936
+ # times the index actually moved.
937
+ marker = @generation.current
938
+ duration = elapsed_ms(started)
939
+ @logger.info("[Woods] watch: #{action} over #{change_set.size} path(s) " \
940
+ "in #{duration}ms → generation #{marker.number}")
941
+
942
+ outcome(action, :running, generation: marker.number, count: change_set.size,
943
+ duration_ms: duration, touched: touched)
944
+ end
945
+
946
+ def log_storm(actionable)
947
+ @logger.info("[Woods] watch: #{actionable} actionable paths changed " \
948
+ "(> #{@full_extraction_threshold}) — full extraction instead of incremental")
949
+ end
950
+
951
+ # rubocop:disable-next Metrics/ParameterLists -- the shape of one cycle's result.
952
+ def outcome(action, state, reason: nil, count: 0, duration_ms: nil, generation: nil, touched: nil)
953
+ generation ||= @generation.current.number
954
+ # Remembered so an `:ignore` batch cannot advertise recovery, and so the
955
+ # heartbeat republishes the truth rather than `:running`.
956
+ @degraded_reason = state == :degraded ? reason : nil
957
+ publish_status(state, reason: reason, generation: generation,
958
+ last_action: action.to_s, last_batch_size: count,
959
+ last_duration_ms: duration_ms)
960
+
961
+ { action: action, state: state, reason: reason, generation: generation,
962
+ count: count, duration_ms: duration_ms, touched: touched }
963
+ end
964
+
965
+ def publish_status(state, reason:, generation: nil, **details)
966
+ record = { state: state, reason: reason,
967
+ generation: generation || @generation.current.number, **details }
968
+ # Kept so the heartbeat can re-stamp exactly this record rather than
969
+ # inventing a fresh one.
970
+ @last_status = record
971
+ @status.write(**record)
972
+ rescue StandardError => e
973
+ @logger.warn("[Woods] watch: could not write status — #{e.message}")
974
+ end
975
+
976
+ # Is a *different* live daemon already maintaining this index?
977
+ #
978
+ # {PipelineLock} keeps two daemons from interleaving writes, but nothing
979
+ # stopped them both existing: the second would poll the same tree, take
980
+ # the lock alternately with the first, and double the extraction work
981
+ # while each carried paths forward past cycles the other had already
982
+ # handled. Worse, both publish status to one file, so `alive?` answers for
983
+ # whichever wrote last and `woods:watch_status` cannot tell you there are
984
+ # two. Cheap to prevent at startup, and a crashed predecessor does not
985
+ # trip it — `Status#alive?` requires the recorded pid to still exist.
986
+ #
987
+ # `WOODS_IGNORE_WATCH=1` overrides, matching what it already means for
988
+ # `woods:incremental`.
989
+ #
990
+ # @return [Boolean]
991
+ def another_daemon_alive?
992
+ return false if ENV['WOODS_IGNORE_WATCH'] == '1'
993
+ return false unless @status.alive?
994
+
995
+ other = @status.read['pid']
996
+ return false if other.nil? || other.to_i == Process.pid
997
+
998
+ @logger.warn(
999
+ "[Woods] a watch daemon (pid #{other}) is already maintaining #{@output_dir} — standing down. " \
1000
+ 'Set WOODS_IGNORE_WATCH=1 to start anyway.'
1001
+ )
1002
+ true
1003
+ end
1004
+
1005
+ # Atomically claim the right to start, closing the race
1006
+ # {#another_daemon_alive?} cannot: two daemons calling this
1007
+ # concurrently both attempt an `O_EXCL` create of the same claim file,
1008
+ # and the filesystem — not thread scheduling — decides which one
1009
+ # actually creates it. The loser gets `Errno::EEXIST` regardless of
1010
+ # which daemon checked {#another_daemon_alive?} first or which one
1011
+ # would have published its status record first.
1012
+ #
1013
+ # `WOODS_IGNORE_WATCH=1` bypasses the claim the same way it bypasses
1014
+ # {#another_daemon_alive?} — forcing a start must not get blocked by
1015
+ # a still-live claim it was explicitly told to override.
1016
+ #
1017
+ # A claim recorded by a pid that no longer exists is reclaimed rather
1018
+ # than left blocking forever, the same "does the recorded pid still
1019
+ # exist" test {Status#alive?} uses. Bounded to a few attempts so a
1020
+ # claim that keeps reappearing (a pathological retry loop, not the
1021
+ # ordinary single-contender case) fails closed instead of spinning.
1022
+ #
1023
+ # @return [Boolean] true when this instance now holds the claim (or
1024
+ # was told to skip claiming entirely)
1025
+ def claim_startup?
1026
+ return true if ENV['WOODS_IGNORE_WATCH'] == '1'
1027
+
1028
+ with_claim_lock do
1029
+ 3.times do
1030
+ return true if create_claim
1031
+ return false unless reclaim_if_stale
1032
+ end
1033
+
1034
+ false
1035
+ end
1036
+ end
1037
+
1038
+ # Serializes reclaim-then-create across starters. Without it the
1039
+ # sequence is read-then-unlink: starter A judges claim S stale and
1040
+ # pauses, B deletes S and publishes its live claim, A resumes and
1041
+ # unlinks B's claim, and both return as owners. An `flock` on a
1042
+ # sidecar file makes the whole loop one critical section; the kernel
1043
+ # releases it if the holder dies. Where the filesystem refuses the
1044
+ # lock (some network mounts) the loop runs unserialized, as before.
1045
+ def with_claim_lock
1046
+ lock = open_claim_lock
1047
+ yield
1048
+ ensure
1049
+ lock&.close
1050
+ end
1051
+
1052
+ def open_claim_lock
1053
+ FileUtils.mkdir_p(@output_dir)
1054
+ # Held open on purpose: the descriptor is the lock; closed in
1055
+ # with_claim_lock's ensure.
1056
+ file = File.open(claim_lock_path, File::RDWR | File::CREAT, 0o644) # rubocop:disable Style/FileOpen
1057
+ file.flock(File::LOCK_EX)
1058
+ file
1059
+ rescue SystemCallError => e
1060
+ file&.close
1061
+ @logger.warn("[Woods] watch: claim lock unavailable on #{@output_dir} (#{e.class}: #{e.message}); " \
1062
+ 'startup claims are not serialized here, so two simultaneous starters can both win')
1063
+ nil
1064
+ end
1065
+
1066
+ def claim_lock_path
1067
+ "#{claim_path}.lock"
1068
+ end
1069
+
1070
+ # @return [Boolean] true when the claim file was created by this call
1071
+ #
1072
+ # Written fully to a temp file first, THEN linked into place — never
1073
+ # `open(O_EXCL)` straight onto +claim_path+ and write after. `File.link`
1074
+ # is atomic and fails closed with `EEXIST` exactly like `O_EXCL` does,
1075
+ # but the instant a reader can see the claim file at all, its content
1076
+ # is already complete. Racing a plain `open`-then-`write` the other way
1077
+ # let a contender's {#stale_claim?} read the file between our create
1078
+ # and our write, see an empty/torn body, misjudge it stale, and delete
1079
+ # the claim we had just won — reintroducing exactly the double-daemon
1080
+ # race this method exists to close.
1081
+ def create_claim
1082
+ FileUtils.mkdir_p(@output_dir)
1083
+ content = JSON.generate(pid: Process.pid, host: Status.host_identity)
1084
+ tmp_path = "#{claim_path}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
1085
+ File.write(tmp_path, content)
1086
+ begin
1087
+ File.link(tmp_path, claim_path)
1088
+ rescue Errno::EPERM, Errno::ENOTSUP
1089
+ # Some filesystems (certain bind mounts, network shares) don't
1090
+ # support hard links at all — that raises EPERM/ENOTSUP rather
1091
+ # than the EEXIST a real conflict would raise. Fall back to a
1092
+ # plain O_EXCL create so the daemon can still start there; this
1093
+ # loses the tmp-write-then-link guarantee that content is
1094
+ # complete before a reader can see the file exist, but the
1095
+ # content here is a few bytes written in one syscall.
1096
+ #
1097
+ # Documented residual (INF-11): on this path a contender reading
1098
+ # between the create and the write still sees an empty claim, judges
1099
+ # it stale and replaces it. {#release_claim}'s verify-before-delete
1100
+ # bounds the damage to one lost claim rather than two daemons
1101
+ # deleting each other's.
1102
+ File.open(claim_path, File::WRONLY | File::CREAT | File::EXCL) { |f| f.write(content) }
1103
+ end
1104
+ @claimed = true
1105
+ true
1106
+ rescue Errno::EEXIST
1107
+ false
1108
+ ensure
1109
+ # `tmp_path` is assigned after `mkdir_p`, so a raise from `mkdir_p`
1110
+ # itself leaves it `nil` — `FileUtils.rm_f(nil)` is a TypeError that
1111
+ # would otherwise replace whatever `mkdir_p` actually raised.
1112
+ FileUtils.rm_f(tmp_path) if tmp_path
1113
+ end
1114
+
1115
+ # @return [Boolean] true when a stale claim was cleared and the caller
1116
+ # should retry {#create_claim}; false when the claim is live, was
1117
+ # already replaced by a winning contender, or the directory
1118
+ # vanished — every case where the caller should give up
1119
+ def reclaim_if_stale
1120
+ snapshot = claim_bytes
1121
+ return false unless snapshot
1122
+ return false unless stale_claim?
1123
+
1124
+ # Race guard: two starters can both read the same dead-pid claim
1125
+ # and both judge it stale before either deletes it. Re-read
1126
+ # immediately before deleting: if the bytes changed since the
1127
+ # snapshot, another daemon's create_claim already replaced the file
1128
+ # with its own live claim, and deleting it now would destroy a claim
1129
+ # we never judged stale. Bytes, not the inode: Linux reuses a freed
1130
+ # inode for the very next file created in the directory, so an
1131
+ # inode comparison passed the replaced claim through.
1132
+ return false unless claim_bytes == snapshot
1133
+
1134
+ FileUtils.rm_f(claim_path)
1135
+ true
1136
+ rescue Errno::ENOENT
1137
+ false
1138
+ end
1139
+
1140
+ # @return [String, nil] the claim file's current bytes, or nil if it
1141
+ # doesn't exist
1142
+ def claim_bytes
1143
+ File.read(claim_path)
1144
+ rescue Errno::ENOENT
1145
+ nil
1146
+ end
1147
+
1148
+ # @return [Boolean] whether the current claim's pid evidence can be
1149
+ # trusted at all and, if so, whether that pid is dead — an unreadable
1150
+ # or already-vanished claim counts as stale too, since it cannot be a
1151
+ # live daemon's claim
1152
+ def stale_claim?
1153
+ record = JSON.parse(File.read(claim_path))
1154
+ return true unless same_claim_host?(record['host'])
1155
+
1156
+ !claim_pid_alive?(record['pid'])
1157
+ rescue JSON::ParserError, SystemCallError
1158
+ true
1159
+ end
1160
+
1161
+ # Mirrors {Status#alive?}'s host check: a pid is only meaningful inside
1162
+ # the namespace that issued it, so a claim recorded on a different host
1163
+ # cannot be judged live on pid evidence alone. Without this, a dead
1164
+ # container daemon's claim (container pid 47) reads as live forever to
1165
+ # a host-side daemon, because host pid 47 almost always exists too — the
1166
+ # same false positive {Status} already guards against for the status
1167
+ # file, just never mirrored here for the claim file.
1168
+ #
1169
+ # A claim with no recorded host predates this field; treated as
1170
+ # same-host so an in-place upgrade does not misjudge its own claim.
1171
+ #
1172
+ # @param host [String, nil]
1173
+ # @return [Boolean]
1174
+ def same_claim_host?(host)
1175
+ host.nil? || host == Status.host_identity
1176
+ end
1177
+
1178
+ # Same semantics as {Status#alive?}'s pid check: signal 0 asks "could I
1179
+ # signal this process?" without sending anything.
1180
+ def claim_pid_alive?(pid)
1181
+ return false unless pid.is_a?(Integer) && pid.positive?
1182
+
1183
+ Process.kill(0, pid)
1184
+ true
1185
+ rescue Errno::ESRCH
1186
+ false
1187
+ rescue Errno::EPERM
1188
+ true
1189
+ end
1190
+
1191
+ # Drop this daemon's startup claim at shutdown.
1192
+ #
1193
+ # Verify-before-delete, mirroring {#reclaim_if_stale}'s snapshot-compare
1194
+ # and `PipelineLock#release` (INF-11). `@claimed` records that *we* once
1195
+ # created the claim, not that the file on disk is still ours: the
1196
+ # no-hardlink fallback leaves a window in which a contender can judge our
1197
+ # half-written claim stale and replace it. Deleting unconditionally then
1198
+ # removes the successor's *live* claim and lets a third starter in while
1199
+ # it is still running.
1200
+ #
1201
+ # An unreadable or unparseable claim is still removed — it cannot be a
1202
+ # live daemon's claim, the same judgement {#stale_claim?} makes.
1203
+ #
1204
+ # @return [void]
1205
+ def release_claim
1206
+ return unless @claimed
1207
+
1208
+ FileUtils.rm_f(claim_path) if own_claim?
1209
+ @claimed = false
1210
+ end
1211
+
1212
+ # @return [Boolean] whether the claim on disk still records this process
1213
+ def own_claim?
1214
+ record = JSON.parse(File.read(claim_path))
1215
+ return true unless record.is_a?(Hash)
1216
+
1217
+ record['pid'] == Process.pid && same_claim_host?(record['host'])
1218
+ rescue JSON::ParserError, SystemCallError
1219
+ true
1220
+ end
1221
+
1222
+ def claim_path
1223
+ File.join(@output_dir, CLAIM_FILENAME)
1224
+ end
1225
+
1226
+ def build_watcher
1227
+ @watcher = Watcher.build(
1228
+ root: @root, ignored: ignored_directories, logger: @logger, force_polling: @force_polling
1229
+ )
1230
+ end
1231
+
1232
+ # The ignore list, plus this daemon's own output directory when it sits
1233
+ # inside the watched tree.
1234
+ #
1235
+ # Every cycle writes `generation.json`, `status.json` and the unit files,
1236
+ # so watching the output directory means each cycle manufactures the
1237
+ # events that trigger the next one — a daemon that never goes idle and an
1238
+ # index that rewrites itself forever. The default `tmp/woods` is already
1239
+ # covered by `tmp` in {Watcher::DEFAULT_IGNORED_DIRECTORIES}, which is why
1240
+ # this has not bitten in practice; a `WOODS_OUTPUT` pointing anywhere else
1241
+ # under the root (`.woods/`, `woods_index/`) had nothing protecting it.
1242
+ #
1243
+ # @return [Array<String>] directory names/prefixes to skip
1244
+ def ignored_directories
1245
+ @ignored_directories ||= [
1246
+ *Watcher::DEFAULT_IGNORED_DIRECTORIES, output_dir_within_root
1247
+ ].compact.uniq
1248
+ end
1249
+
1250
+ # @return [String, nil] output dir relative to the root, or nil when it
1251
+ # lives outside the watched tree entirely
1252
+ def output_dir_within_root
1253
+ base = resolve_path(@root)
1254
+ out = resolve_path(@output_dir)
1255
+ return nil if base.nil? || out.nil?
1256
+ return nil unless out.start_with?("#{base}/")
1257
+
1258
+ out.delete_prefix("#{base}/")
1259
+ end
1260
+
1261
+ # `realpath` so a symlinked root or output dir still compares, falling
1262
+ # back to `expand_path` because the output directory need not exist yet on
1263
+ # a first run.
1264
+ def resolve_path(path)
1265
+ File.realpath(path)
1266
+ rescue SystemCallError
1267
+ File.expand_path(path)
1268
+ end
1269
+
1270
+ def monotonic_now
1271
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1272
+ end
1273
+
1274
+ def elapsed_ms(started)
1275
+ ((monotonic_now - started) * 1000).round
1276
+ end
1277
+
1278
+ def default_logger
1279
+ defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger ? Rails.logger : NullLogger.new
1280
+ end
1281
+
1282
+ # Used when there is no Rails logger — a daemon that can't log is still
1283
+ # better than one that raises on its first message.
1284
+ class NullLogger
1285
+ def info(*); end
1286
+ def warn(*); end
1287
+ def error(*); end
1288
+ end
1289
+
1290
+ # Adapter over `Rails.application.reloader`, isolated so the daemon can
1291
+ # be driven without Rails in specs.
1292
+ #
1293
+ # `enabled?` matters: an app booted with `config.enable_reloading =
1294
+ # false` (the production default, and common in staging-shaped dev
1295
+ # containers) cannot reload at all. The daemon escalates `:reload` to
1296
+ # `:restart` in that case rather than extracting against constants that
1297
+ # no longer match their source.
1298
+ class RailsReloader
1299
+ # Whether this process can reload code.
1300
+ #
1301
+ # The two spellings are not two semantics: `enable_reloading` is
1302
+ # *defined* as `!cache_classes` from 7.1 on, so both branches compute
1303
+ # the same thing and the guard is only about which method exists (6.0
1304
+ # through 7.0 have no `enable_reloading` at all). Deliberately reading
1305
+ # the same value Rails' own finisher gates the reloader on — including
1306
+ # the case where an app never sets `cache_classes` and it stays `nil`,
1307
+ # which Rails reads as reloading-enabled and so must we.
1308
+ #
1309
+ # @return [Boolean]
1310
+ def enabled?
1311
+ return false unless defined?(Rails) && Rails.application
1312
+
1313
+ config = Rails.application.config
1314
+ return config.enable_reloading if config.respond_to?(:enable_reloading)
1315
+
1316
+ !config.cache_classes
1317
+ rescue StandardError
1318
+ false
1319
+ end
1320
+
1321
+ # Reload the app's autoloaded constants.
1322
+ #
1323
+ # Deliberately bare. Unloading constants while other threads execute
1324
+ # autoloaded code — the embedded placement this class' doc blesses,
1325
+ # inside a dev server — needs the interlock's unload lock held, or you
1326
+ # get a `NameError` in an unrelated request or a deadlock against a
1327
+ # thread mid-autoload. `reload!` already takes it: the instance's
1328
+ # `class_unload!` calls `require_unload_lock!`, which is
1329
+ # `interlock.start_unloading`, and Rails' finisher registers that
1330
+ # callback whenever reloading is enabled. Wrapping this call in
1331
+ # `interlock.unloading` would re-acquire the same exclusive lock the
1332
+ # block below is about to take.
1333
+ #
1334
+ # `spec/integration/watch_daemon_spec.rb` pins that, so a Rails release
1335
+ # that stopped locking here fails rather than quietly needing a wrapper
1336
+ # nobody remembers to re-add.
1337
+ #
1338
+ # @return [void]
1339
+ def reload!
1340
+ Rails.application.reloader.reload!
1341
+ end
1342
+ end
1343
+ end
1344
+ end
1345
+ end