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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'set'
|
|
4
|
+
require_relative '../dependency_graph'
|
|
4
5
|
|
|
5
6
|
module Woods
|
|
6
7
|
module RubyAnalyzer
|
|
@@ -89,16 +90,25 @@ module Woods
|
|
|
89
90
|
lines << ' end'
|
|
90
91
|
end
|
|
91
92
|
|
|
92
|
-
# Render edges
|
|
93
|
+
# Render edges. `to_h` has emitted `[{target:, via:}]` hashes since the
|
|
94
|
+
# via migration; iterating them as bare targets made `nodes.key?` false
|
|
95
|
+
# for every edge, so the map rendered nodes and nothing else (EXTB-6).
|
|
96
|
+
# Legacy bare-string edges still load — {DependencyGraph.normalize_edges}
|
|
97
|
+
# accepts both shapes.
|
|
93
98
|
seen_edges = Set.new
|
|
94
99
|
edges.each do |source, targets|
|
|
95
|
-
Array(targets).each do |
|
|
96
|
-
|
|
100
|
+
Woods::DependencyGraph.normalize_edges(Array(targets)).each do |edge|
|
|
101
|
+
target = edge[:target]
|
|
102
|
+
next unless target && nodes.key?(target)
|
|
97
103
|
|
|
98
104
|
edge_key = "#{sanitize_id(source)}->#{sanitize_id(target)}"
|
|
99
105
|
next unless seen_edges.add?(edge_key)
|
|
100
106
|
|
|
101
|
-
lines <<
|
|
107
|
+
lines << if edge[:via]
|
|
108
|
+
" #{sanitize_id(source)} -->|#{edge[:via]}| #{sanitize_id(target)}"
|
|
109
|
+
else
|
|
110
|
+
" #{sanitize_id(source)} --> #{sanitize_id(target)}"
|
|
111
|
+
end
|
|
102
112
|
end
|
|
103
113
|
end
|
|
104
114
|
|
|
@@ -63,7 +63,7 @@ module Woods
|
|
|
63
63
|
body_children = (node.children || [])[body_offset..] || []
|
|
64
64
|
|
|
65
65
|
visibility_tracker = VisibilityTracker.new
|
|
66
|
-
inner_ns =
|
|
66
|
+
inner_ns = fqn.split('::')
|
|
67
67
|
|
|
68
68
|
body_children.each do |child|
|
|
69
69
|
next unless child.is_a?(Ast::Node)
|
|
@@ -21,7 +21,10 @@ module Woods
|
|
|
21
21
|
#
|
|
22
22
|
# @yield Block to trace
|
|
23
23
|
# @return [Array<Hash>] Collected trace events
|
|
24
|
+
# @raise [ArgumentError] if no block is given
|
|
24
25
|
def self.record(&block)
|
|
26
|
+
raise ArgumentError, 'block required' unless block
|
|
27
|
+
|
|
25
28
|
traces = []
|
|
26
29
|
|
|
27
30
|
trace = TracePoint.new(:call, :return) do |tp|
|
data/lib/woods/ruby_analyzer.rb
CHANGED
|
@@ -21,11 +21,14 @@ module Woods
|
|
|
21
21
|
class << self
|
|
22
22
|
# Analyze Ruby source files and produce ExtractedUnit objects.
|
|
23
23
|
#
|
|
24
|
-
# @param paths [Array<String
|
|
24
|
+
# @param paths [Array<String>, nil] File paths or directories to analyze
|
|
25
|
+
# @param sources [Hash{String => String}, nil] Pre-read source keyed by
|
|
26
|
+
# absolute file path. This lets callers analyze one consistent source
|
|
27
|
+
# snapshot rather than re-reading files while they may be changing.
|
|
25
28
|
# @param trace_data [Array<Hash>, nil] Optional runtime trace data for enrichment
|
|
26
29
|
# @return [Array<ExtractedUnit>] All extracted units
|
|
27
|
-
def analyze(paths
|
|
28
|
-
files = discover_files(paths)
|
|
30
|
+
def analyze(paths: nil, sources: nil, trace_data: nil)
|
|
31
|
+
files = sources ? sources.keys.sort : discover_files(Array(paths))
|
|
29
32
|
return [] if files.empty?
|
|
30
33
|
|
|
31
34
|
parser = Ast::Parser.new
|
|
@@ -36,7 +39,7 @@ module Woods
|
|
|
36
39
|
units = []
|
|
37
40
|
|
|
38
41
|
files.each do |file_path|
|
|
39
|
-
source =
|
|
42
|
+
source = source_for(file_path, sources)
|
|
40
43
|
next unless source
|
|
41
44
|
|
|
42
45
|
units.concat(class_analyzer.analyze(source: source, file_path: file_path))
|
|
@@ -73,12 +76,25 @@ module Woods
|
|
|
73
76
|
files.uniq
|
|
74
77
|
end
|
|
75
78
|
|
|
79
|
+
def source_for(file_path, sources)
|
|
80
|
+
return sources.fetch(file_path) if sources
|
|
81
|
+
|
|
82
|
+
read_file(file_path)
|
|
83
|
+
end
|
|
84
|
+
|
|
76
85
|
# Read a file safely, returning nil on failure.
|
|
77
86
|
#
|
|
87
|
+
# Ruby source defaults to UTF-8, so the read is pinned to UTF-8
|
|
88
|
+
# rather than the process default external encoding. Under LANG=C
|
|
89
|
+
# (US-ASCII) a bare File.read tags multibyte source, such as an em
|
|
90
|
+
# dash in a comment, as invalid and JSON generation raises
|
|
91
|
+
# Encoding::InvalidByteSequenceError out of the analysis.
|
|
92
|
+
#
|
|
78
93
|
# @param path [String] File path
|
|
79
94
|
# @return [String, nil] File contents or nil
|
|
80
95
|
def read_file(path)
|
|
81
|
-
File.read(path)
|
|
96
|
+
content = File.read(path, encoding: Encoding::UTF_8)
|
|
97
|
+
content.valid_encoding? ? content : content.scrub
|
|
82
98
|
rescue StandardError
|
|
83
99
|
nil
|
|
84
100
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'fileutils'
|
|
5
|
+
require 'tempfile'
|
|
5
6
|
require_relative 'store'
|
|
6
7
|
|
|
7
8
|
module Woods
|
|
@@ -11,22 +12,34 @@ module Woods
|
|
|
11
12
|
# Sessions are stored as individual files in a configurable directory:
|
|
12
13
|
# {base_dir}/{session_id}.jsonl
|
|
13
14
|
#
|
|
14
|
-
#
|
|
15
|
+
# Bounded JSONL history replaced atomically under a cross-process file lock.
|
|
16
|
+
# Zero external dependencies.
|
|
15
17
|
#
|
|
16
18
|
# @example
|
|
17
19
|
# store = FileStore.new(base_dir: "tmp/woods/sessions")
|
|
18
20
|
# store.record("abc123", { controller: "PostsController", action: "create" })
|
|
19
21
|
# store.read("abc123") # => [{ "controller" => "PostsController", ... }]
|
|
20
22
|
#
|
|
21
|
-
class FileStore < Store
|
|
23
|
+
class FileStore < Store # rubocop:disable Metrics/ClassLength
|
|
24
|
+
DEFAULT_MAX_SESSIONS = 1_000
|
|
25
|
+
DEFAULT_MAX_REQUESTS = 1_000
|
|
26
|
+
|
|
22
27
|
# @param base_dir [String] Directory for session JSONL files
|
|
23
|
-
def initialize(base_dir:
|
|
28
|
+
def initialize(base_dir:, ttl: nil, max_sessions: DEFAULT_MAX_SESSIONS,
|
|
29
|
+
max_requests_per_session: DEFAULT_MAX_REQUESTS, clock: -> { Time.now })
|
|
24
30
|
super()
|
|
31
|
+
validate_limit!(:max_sessions, max_sessions)
|
|
32
|
+
validate_limit!(:max_requests_per_session, max_requests_per_session)
|
|
25
33
|
@base_dir = base_dir
|
|
34
|
+
@ttl = ttl
|
|
35
|
+
@max_sessions = max_sessions
|
|
36
|
+
@max_requests_per_session = max_requests_per_session
|
|
37
|
+
@clock = clock
|
|
38
|
+
@mutex = Mutex.new
|
|
26
39
|
FileUtils.mkdir_p(@base_dir)
|
|
27
40
|
end
|
|
28
41
|
|
|
29
|
-
#
|
|
42
|
+
# Add a request record to a bounded session JSONL history.
|
|
30
43
|
#
|
|
31
44
|
# Uses file locking (LOCK_EX) for concurrency safety.
|
|
32
45
|
#
|
|
@@ -34,12 +47,22 @@ module Woods
|
|
|
34
47
|
# @param request_data [Hash] Request metadata to store
|
|
35
48
|
# @return [void]
|
|
36
49
|
def record(session_id, request_data)
|
|
37
|
-
path = session_path(session_id)
|
|
38
50
|
line = "#{JSON.generate(request_data)}\n"
|
|
39
51
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
52
|
+
with_store_lock do
|
|
53
|
+
path = migrate_legacy_session!(session_id)
|
|
54
|
+
# Explicit UTF-8 on every read in this file: the JSONL is written as
|
|
55
|
+
# raw UTF-8 bytes, so a bare read tags the lines with the process
|
|
56
|
+
# default external encoding and one non-ASCII request path (a
|
|
57
|
+
# localized route) breaks `record`'s history join, `read`,
|
|
58
|
+
# `sessions`, and the `session_trace` MCP tool under LANG=C (R1-1).
|
|
59
|
+
lines = if File.exist?(path)
|
|
60
|
+
File.readlines(path, encoding: Encoding::UTF_8).last(@max_requests_per_session - 1)
|
|
61
|
+
else
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
atomic_replace(path, (lines << line).join)
|
|
65
|
+
prune_sessions!
|
|
43
66
|
end
|
|
44
67
|
end
|
|
45
68
|
|
|
@@ -48,10 +71,18 @@ module Woods
|
|
|
48
71
|
# @param session_id [String] The session identifier
|
|
49
72
|
# @return [Array<Hash>] Request records, oldest first
|
|
50
73
|
def read(session_id)
|
|
51
|
-
|
|
52
|
-
|
|
74
|
+
lines = with_store_lock do
|
|
75
|
+
path = migrate_legacy_session!(session_id)
|
|
76
|
+
return [] unless File.exist?(path)
|
|
77
|
+
|
|
78
|
+
if expired?(path)
|
|
79
|
+
FileUtils.rm_f(path)
|
|
80
|
+
return []
|
|
81
|
+
end
|
|
53
82
|
|
|
54
|
-
|
|
83
|
+
File.readlines(path, encoding: Encoding::UTF_8)
|
|
84
|
+
end
|
|
85
|
+
lines.filter_map do |line|
|
|
55
86
|
stripped = line.strip
|
|
56
87
|
next if stripped.empty?
|
|
57
88
|
|
|
@@ -66,11 +97,14 @@ module Woods
|
|
|
66
97
|
# @param limit [Integer] Maximum number of sessions to return
|
|
67
98
|
# @return [Array<Hash>] Session summaries
|
|
68
99
|
def sessions(limit: 20)
|
|
69
|
-
|
|
70
|
-
|
|
100
|
+
files = with_store_lock do
|
|
101
|
+
migrate_legacy_sessions!
|
|
102
|
+
prune_expired!
|
|
103
|
+
session_files.sort_by { |file| -File.mtime(file).to_f }.first(limit)
|
|
104
|
+
end
|
|
71
105
|
|
|
72
|
-
files.
|
|
73
|
-
session_id = File.basename(file, '.jsonl')
|
|
106
|
+
files.map do |file|
|
|
107
|
+
session_id = restore_session_id(File.basename(file, '.jsonl'))
|
|
74
108
|
session_summary(session_id, read(session_id))
|
|
75
109
|
end
|
|
76
110
|
end
|
|
@@ -80,16 +114,17 @@ module Woods
|
|
|
80
114
|
# @param session_id [String] The session identifier
|
|
81
115
|
# @return [void]
|
|
82
116
|
def clear(session_id)
|
|
83
|
-
|
|
84
|
-
|
|
117
|
+
with_store_lock do
|
|
118
|
+
FileUtils.rm_f(session_path(session_id))
|
|
119
|
+
FileUtils.rm_f(legacy_session_path(session_id))
|
|
120
|
+
end
|
|
85
121
|
end
|
|
86
122
|
|
|
87
123
|
# Remove all session data.
|
|
88
124
|
#
|
|
89
125
|
# @return [void]
|
|
90
126
|
def clear_all
|
|
91
|
-
|
|
92
|
-
Dir.glob(pattern).each { |f| File.delete(f) }
|
|
127
|
+
with_store_lock { session_files.each { |file| File.delete(file) } }
|
|
93
128
|
end
|
|
94
129
|
|
|
95
130
|
private
|
|
@@ -99,6 +134,90 @@ module Woods
|
|
|
99
134
|
def session_path(session_id)
|
|
100
135
|
File.join(@base_dir, "#{sanitize_session_id(session_id)}.jsonl")
|
|
101
136
|
end
|
|
137
|
+
|
|
138
|
+
def session_files
|
|
139
|
+
Dir.glob(File.join(@base_dir, '*.jsonl'))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def with_store_lock
|
|
143
|
+
@mutex.synchronize do
|
|
144
|
+
File.open(File.join(@base_dir, '.woods-session-store.lock'), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
145
|
+
lock.flock(File::LOCK_EX)
|
|
146
|
+
yield
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def legacy_session_path(session_id)
|
|
152
|
+
raw = session_id.to_s
|
|
153
|
+
return unless raw.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
154
|
+
|
|
155
|
+
File.join(@base_dir, "#{raw}.jsonl")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def migrate_legacy_session!(session_id)
|
|
159
|
+
target = session_path(session_id)
|
|
160
|
+
legacy = legacy_session_path(session_id)
|
|
161
|
+
return target unless legacy && File.exist?(legacy)
|
|
162
|
+
|
|
163
|
+
if File.exist?(target)
|
|
164
|
+
atomic_replace(target,
|
|
165
|
+
File.read(legacy, encoding: Encoding::UTF_8) +
|
|
166
|
+
File.read(target, encoding: Encoding::UTF_8))
|
|
167
|
+
FileUtils.rm_f(legacy)
|
|
168
|
+
else
|
|
169
|
+
File.rename(legacy, target)
|
|
170
|
+
fsync_parent
|
|
171
|
+
end
|
|
172
|
+
target
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def migrate_legacy_sessions!
|
|
176
|
+
session_files.each do |path|
|
|
177
|
+
basename = File.basename(path, '.jsonl')
|
|
178
|
+
migrate_legacy_session!(basename) unless basename.start_with?('b64.')
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def atomic_replace(path, content)
|
|
183
|
+
temp = Tempfile.new([".#{File.basename(path)}-", '.tmp'], @base_dir)
|
|
184
|
+
temp.chmod(0o600)
|
|
185
|
+
temp.write(content)
|
|
186
|
+
temp.flush
|
|
187
|
+
temp.fsync
|
|
188
|
+
temp.close
|
|
189
|
+
File.rename(temp.path, path)
|
|
190
|
+
fsync_parent
|
|
191
|
+
ensure
|
|
192
|
+
temp&.close
|
|
193
|
+
temp&.unlink
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def fsync_parent
|
|
197
|
+
File.open(@base_dir, File::RDONLY, &:fsync)
|
|
198
|
+
rescue Errno::EINVAL, Errno::ENOTSUP, Errno::EISDIR
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def expired?(path)
|
|
203
|
+
@ttl && @clock.call >= File.mtime(path) + @ttl
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def prune_expired!
|
|
207
|
+
session_files.each { |file| FileUtils.rm_f(file) if expired?(file) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def prune_sessions!
|
|
211
|
+
prune_expired!
|
|
212
|
+
stale = session_files.sort_by { |file| -File.mtime(file).to_f }.drop(@max_sessions)
|
|
213
|
+
stale.each { |file| FileUtils.rm_f(file) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def validate_limit!(name, value)
|
|
217
|
+
return if value.is_a?(Integer) && value.positive?
|
|
218
|
+
|
|
219
|
+
raise ArgumentError, "#{name} must be a positive Integer"
|
|
220
|
+
end
|
|
102
221
|
end
|
|
103
222
|
end
|
|
104
223
|
end
|
|
@@ -85,7 +85,7 @@ module Woods
|
|
|
85
85
|
# @param env [Hash] Rack environment
|
|
86
86
|
# @param status [Integer] HTTP response status
|
|
87
87
|
# @param duration_ms [Integer] Request duration in milliseconds
|
|
88
|
-
# rubocop:disable Metrics/MethodLength
|
|
88
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
89
89
|
def record_request(env, status, duration_ms)
|
|
90
90
|
path = env['PATH_INFO'] || ''
|
|
91
91
|
return if excluded?(path)
|
|
@@ -116,7 +116,6 @@ module Woods
|
|
|
116
116
|
|
|
117
117
|
@store.record(session_id, request_data)
|
|
118
118
|
end
|
|
119
|
-
# rubocop:enable Metrics/MethodLength
|
|
120
119
|
|
|
121
120
|
# Extract session ID: X-Trace-Session header first, then session cookie, then fallback.
|
|
122
121
|
#
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'time'
|
|
4
5
|
require_relative 'store'
|
|
5
6
|
|
|
7
|
+
# Woods::SessionTracerError is defined in the entry point; required here so
|
|
8
|
+
# this file can be loaded directly. Without it a narrow entry point (a custom
|
|
9
|
+
# boot, a script) turned the documented "add `gem \"redis\"`" error into a
|
|
10
|
+
# NameError naming a missing constant (INF-8).
|
|
11
|
+
require 'woods'
|
|
12
|
+
|
|
6
13
|
module Woods
|
|
7
14
|
module SessionTracer
|
|
8
|
-
# Redis-backed session store using Lists.
|
|
15
|
+
# Redis-backed session store using Lists plus a recency ZSET.
|
|
9
16
|
#
|
|
10
17
|
# Each session is stored as a Redis List keyed `woods:session:{id}`.
|
|
11
|
-
# RPUSH per request for append-only ordering. Native TTL for automatic
|
|
18
|
+
# RPUSH per request for append-only ordering. Native TTL for automatic
|
|
19
|
+
# cleanup. The `woods:sessions` index is a ZSET scored by each session's
|
|
20
|
+
# last request timestamp, so eviction touches O(log n) members instead
|
|
21
|
+
# of re-reading every session's history (audit P4). A legacy SET left by
|
|
22
|
+
# an earlier version is migrated on the first record.
|
|
12
23
|
#
|
|
13
24
|
# Requires the `redis` gem at runtime.
|
|
14
25
|
#
|
|
@@ -18,11 +29,37 @@ module Woods
|
|
|
18
29
|
#
|
|
19
30
|
class RedisStore < Store
|
|
20
31
|
KEY_PREFIX = 'woods:session:'
|
|
32
|
+
# Recency index: ZSET of session_id => last-request epoch. Was a SET
|
|
33
|
+
# before the zset migration; legacy deployments migrate through the
|
|
34
|
+
# atomic script below.
|
|
21
35
|
SESSIONS_KEY = 'woods:sessions'
|
|
36
|
+
DEFAULT_MAX_SESSIONS = 1_000
|
|
37
|
+
DEFAULT_MAX_REQUESTS = 1_000
|
|
38
|
+
|
|
39
|
+
# Recency-index update for one record, executed server-side as one
|
|
40
|
+
# atomic step: type check, legacy SET transfer, insertion of the
|
|
41
|
+
# current member. Redis runs a script without interleaving, so two
|
|
42
|
+
# writers racing a legacy index can neither observe the SET with one
|
|
43
|
+
# writer and erase the other's members, nor fail with WRONGTYPE after
|
|
44
|
+
# the other converted; a writer arriving on an already-converted (or
|
|
45
|
+
# fresh) key takes the plain ZADD path of the same script.
|
|
46
|
+
INDEX_UPDATE_SCRIPT = <<~LUA
|
|
47
|
+
local kind = redis.call('TYPE', KEYS[1]).ok
|
|
48
|
+
if kind == 'set' then
|
|
49
|
+
local legacy = redis.call('SMEMBERS', KEYS[1])
|
|
50
|
+
redis.call('DEL', KEYS[1])
|
|
51
|
+
for _, member in ipairs(legacy) do
|
|
52
|
+
redis.call('ZADD', KEYS[1], 0, member)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
redis.call('ZADD', KEYS[1], tonumber(ARGV[1]), ARGV[2])
|
|
56
|
+
return 1
|
|
57
|
+
LUA
|
|
22
58
|
|
|
23
59
|
# @param redis [Redis] A Redis client instance
|
|
24
60
|
# @param ttl [Integer, nil] Time-to-live in seconds for session keys (nil = no expiry)
|
|
25
|
-
def initialize(redis:, ttl: nil
|
|
61
|
+
def initialize(redis:, ttl: nil, max_sessions: DEFAULT_MAX_SESSIONS,
|
|
62
|
+
max_requests_per_session: DEFAULT_MAX_REQUESTS)
|
|
26
63
|
super()
|
|
27
64
|
unless defined?(::Redis)
|
|
28
65
|
raise SessionTracerError, 'The redis gem is required for RedisStore. Add `gem "redis"` to your Gemfile.'
|
|
@@ -30,6 +67,8 @@ module Woods
|
|
|
30
67
|
|
|
31
68
|
@redis = redis
|
|
32
69
|
@ttl = ttl
|
|
70
|
+
@max_sessions = max_sessions
|
|
71
|
+
@max_requests_per_session = max_requests_per_session
|
|
33
72
|
end
|
|
34
73
|
|
|
35
74
|
# Append a request record to a session's Redis List.
|
|
@@ -40,8 +79,10 @@ module Woods
|
|
|
40
79
|
def record(session_id, request_data)
|
|
41
80
|
key = session_key(session_id)
|
|
42
81
|
@redis.rpush(key, JSON.generate(request_data))
|
|
82
|
+
@redis.ltrim(key, -@max_requests_per_session, -1)
|
|
43
83
|
@redis.expire(key, @ttl) if @ttl
|
|
44
|
-
|
|
84
|
+
index_session(session_id, request_data)
|
|
85
|
+
prune_sessions(session_id)
|
|
45
86
|
end
|
|
46
87
|
|
|
47
88
|
# Read all request records for a session.
|
|
@@ -62,18 +103,28 @@ module Woods
|
|
|
62
103
|
# @param limit [Integer] Maximum number of sessions to return
|
|
63
104
|
# @return [Array<Hash>] Session summaries
|
|
64
105
|
def sessions(limit: 20)
|
|
65
|
-
all_ids = @redis.
|
|
106
|
+
all_ids = @redis.zrange(SESSIONS_KEY, 0, -1)
|
|
66
107
|
|
|
67
108
|
# Filter to sessions that still have data (TTL may have expired)
|
|
68
109
|
active = all_ids.select { |id| @redis.exists?(session_key(id)) }
|
|
69
110
|
|
|
70
|
-
# Remove expired session IDs from the
|
|
111
|
+
# Remove expired session IDs from the index
|
|
71
112
|
expired = all_ids - active
|
|
72
|
-
expired.each { |id| @redis.
|
|
113
|
+
expired.each { |id| @redis.zrem(SESSIONS_KEY, id) } if expired.any?
|
|
73
114
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
115
|
+
# Redis sorted sets order by score, but a summary's last_request is
|
|
116
|
+
# the payload timestamp of the session's last record — the same
|
|
117
|
+
# source the score is derived from. Ordering the built summaries by
|
|
118
|
+
# that field keeps the exact ordering contract (#218 / B-105) even
|
|
119
|
+
# where a clock crept between score and payload.
|
|
120
|
+
#
|
|
121
|
+
# This still reads every active session rather than `limit` of them,
|
|
122
|
+
# as before; the zset made *eviction* the O(log n) path.
|
|
123
|
+
active
|
|
124
|
+
.map { |session_id| session_summary(session_id, read(session_id)) }
|
|
125
|
+
.sort_by { |summary| recency_key(summary) }
|
|
126
|
+
.reverse
|
|
127
|
+
.first(limit)
|
|
77
128
|
end
|
|
78
129
|
|
|
79
130
|
# Remove all data for a single session.
|
|
@@ -82,14 +133,14 @@ module Woods
|
|
|
82
133
|
# @return [void]
|
|
83
134
|
def clear(session_id)
|
|
84
135
|
@redis.del(session_key(session_id))
|
|
85
|
-
@redis.
|
|
136
|
+
@redis.zrem(SESSIONS_KEY, session_id)
|
|
86
137
|
end
|
|
87
138
|
|
|
88
139
|
# Remove all session data.
|
|
89
140
|
#
|
|
90
141
|
# @return [void]
|
|
91
142
|
def clear_all
|
|
92
|
-
all_ids = @redis.
|
|
143
|
+
all_ids = @redis.zrange(SESSIONS_KEY, 0, -1)
|
|
93
144
|
all_ids.each { |id| @redis.del(session_key(id)) }
|
|
94
145
|
@redis.del(SESSIONS_KEY)
|
|
95
146
|
end
|
|
@@ -101,6 +152,65 @@ module Woods
|
|
|
101
152
|
def session_key(session_id)
|
|
102
153
|
"#{KEY_PREFIX}#{sanitize_session_id(session_id)}"
|
|
103
154
|
end
|
|
155
|
+
|
|
156
|
+
# Record the session in the recency index, scored by the request's own
|
|
157
|
+
# timestamp so eviction order matches the last-request ordering
|
|
158
|
+
# {#sessions} reports. The whole update — a legacy SET migration
|
|
159
|
+
# included — runs as one atomic script (see {INDEX_UPDATE_SCRIPT}).
|
|
160
|
+
#
|
|
161
|
+
# @param session_id [String]
|
|
162
|
+
# @param request_data [Hash]
|
|
163
|
+
# @return [void]
|
|
164
|
+
def index_session(session_id, request_data)
|
|
165
|
+
@redis.eval(INDEX_UPDATE_SCRIPT, keys: [SESSIONS_KEY], argv: [recency_score(request_data), session_id])
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @param request_data [Hash]
|
|
169
|
+
# @return [Float] Request timestamp as an epoch, or write time
|
|
170
|
+
def recency_score(request_data)
|
|
171
|
+
timestamp = request_data['timestamp'] if request_data.is_a?(Hash)
|
|
172
|
+
timestamp.is_a?(String) ? Time.iso8601(timestamp).to_f : Time.now.to_f
|
|
173
|
+
rescue ArgumentError
|
|
174
|
+
Time.now.to_f
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Evict the oldest sessions once the recency index overflows
|
|
178
|
+
# `@max_sessions`.
|
|
179
|
+
#
|
|
180
|
+
# The ZSET is scored by last request timestamp (see {#index_session}),
|
|
181
|
+
# so the `overflow + 1` lowest-scoring members contain the oldest
|
|
182
|
+
# `overflow` sessions other than the one just recorded. That matches
|
|
183
|
+
# the FileStore twin's oldest-by-mtime eviction without reading any
|
|
184
|
+
# session's history — the pre-zset implementation re-read every
|
|
185
|
+
# candidate on every record once the cap was reached (audit P4).
|
|
186
|
+
#
|
|
187
|
+
# @param current_session_id [String] never evicted, even if oldest
|
|
188
|
+
# @return [void]
|
|
189
|
+
def prune_sessions(current_session_id)
|
|
190
|
+
overflow = @redis.zcard(SESSIONS_KEY) - @max_sessions
|
|
191
|
+
return unless overflow.positive?
|
|
192
|
+
|
|
193
|
+
victims = @redis.zrange(SESSIONS_KEY, 0, overflow)
|
|
194
|
+
.reject { |id| id == current_session_id }
|
|
195
|
+
.first(overflow)
|
|
196
|
+
|
|
197
|
+
victims.each do |id|
|
|
198
|
+
@redis.del(session_key(id))
|
|
199
|
+
@redis.zrem(SESSIONS_KEY, id)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Sort key for {#sessions}: most recent request first.
|
|
204
|
+
#
|
|
205
|
+
# Timestamps are ISO-8601 strings, which sort correctly as strings. A
|
|
206
|
+
# session with no requests (all entries expired mid-read) sorts last
|
|
207
|
+
# rather than raising on a nil comparison.
|
|
208
|
+
#
|
|
209
|
+
# @param summary [Hash]
|
|
210
|
+
# @return [String]
|
|
211
|
+
def recency_key(summary)
|
|
212
|
+
summary['last_request'].to_s
|
|
213
|
+
end
|
|
104
214
|
end
|
|
105
215
|
end
|
|
106
216
|
end
|