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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- 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'
|