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