woods 1.0.0
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 +7 -0
- data/CHANGELOG.md +89 -0
- data/CODE_OF_CONDUCT.md +83 -0
- data/CONTRIBUTING.md +65 -0
- data/LICENSE.txt +21 -0
- data/README.md +406 -0
- data/exe/woods-console +59 -0
- data/exe/woods-console-mcp +22 -0
- data/exe/woods-mcp +34 -0
- data/exe/woods-mcp-http +37 -0
- data/exe/woods-mcp-start +58 -0
- data/lib/generators/woods/install_generator.rb +32 -0
- data/lib/generators/woods/pgvector_generator.rb +37 -0
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +15 -0
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +43 -0
- data/lib/tasks/woods.rake +621 -0
- data/lib/tasks/woods_evaluation.rake +115 -0
- data/lib/woods/ast/call_site_extractor.rb +106 -0
- data/lib/woods/ast/method_extractor.rb +71 -0
- data/lib/woods/ast/node.rb +116 -0
- data/lib/woods/ast/parser.rb +614 -0
- data/lib/woods/ast.rb +6 -0
- data/lib/woods/builder.rb +200 -0
- data/lib/woods/cache/cache_middleware.rb +199 -0
- data/lib/woods/cache/cache_store.rb +264 -0
- data/lib/woods/cache/redis_cache_store.rb +116 -0
- data/lib/woods/cache/solid_cache_store.rb +111 -0
- data/lib/woods/chunking/chunk.rb +84 -0
- data/lib/woods/chunking/semantic_chunker.rb +295 -0
- data/lib/woods/console/adapters/cache_adapter.rb +58 -0
- data/lib/woods/console/adapters/good_job_adapter.rb +33 -0
- data/lib/woods/console/adapters/job_adapter.rb +68 -0
- data/lib/woods/console/adapters/sidekiq_adapter.rb +33 -0
- data/lib/woods/console/adapters/solid_queue_adapter.rb +33 -0
- data/lib/woods/console/audit_logger.rb +75 -0
- data/lib/woods/console/bridge.rb +177 -0
- data/lib/woods/console/confirmation.rb +90 -0
- data/lib/woods/console/connection_manager.rb +173 -0
- data/lib/woods/console/console_response_renderer.rb +74 -0
- data/lib/woods/console/embedded_executor.rb +373 -0
- data/lib/woods/console/model_validator.rb +81 -0
- data/lib/woods/console/rack_middleware.rb +87 -0
- data/lib/woods/console/safe_context.rb +82 -0
- data/lib/woods/console/server.rb +612 -0
- data/lib/woods/console/sql_validator.rb +172 -0
- data/lib/woods/console/tools/tier1.rb +118 -0
- data/lib/woods/console/tools/tier2.rb +117 -0
- data/lib/woods/console/tools/tier3.rb +110 -0
- data/lib/woods/console/tools/tier4.rb +79 -0
- data/lib/woods/coordination/pipeline_lock.rb +109 -0
- data/lib/woods/cost_model/embedding_cost.rb +88 -0
- data/lib/woods/cost_model/estimator.rb +128 -0
- data/lib/woods/cost_model/provider_pricing.rb +67 -0
- data/lib/woods/cost_model/storage_cost.rb +52 -0
- data/lib/woods/cost_model.rb +22 -0
- data/lib/woods/db/migrations/001_create_units.rb +38 -0
- data/lib/woods/db/migrations/002_create_edges.rb +35 -0
- data/lib/woods/db/migrations/003_create_embeddings.rb +37 -0
- data/lib/woods/db/migrations/004_create_snapshots.rb +45 -0
- data/lib/woods/db/migrations/005_create_snapshot_units.rb +40 -0
- data/lib/woods/db/migrations/006_rename_tables.rb +34 -0
- data/lib/woods/db/migrator.rb +73 -0
- data/lib/woods/db/schema_version.rb +73 -0
- data/lib/woods/dependency_graph.rb +236 -0
- data/lib/woods/embedding/indexer.rb +140 -0
- data/lib/woods/embedding/openai.rb +126 -0
- data/lib/woods/embedding/provider.rb +162 -0
- data/lib/woods/embedding/text_preparer.rb +112 -0
- data/lib/woods/evaluation/baseline_runner.rb +115 -0
- data/lib/woods/evaluation/evaluator.rb +139 -0
- data/lib/woods/evaluation/metrics.rb +79 -0
- data/lib/woods/evaluation/query_set.rb +148 -0
- data/lib/woods/evaluation/report_generator.rb +90 -0
- data/lib/woods/extracted_unit.rb +145 -0
- data/lib/woods/extractor.rb +1028 -0
- data/lib/woods/extractors/action_cable_extractor.rb +201 -0
- data/lib/woods/extractors/ast_source_extraction.rb +46 -0
- data/lib/woods/extractors/behavioral_profile.rb +309 -0
- data/lib/woods/extractors/caching_extractor.rb +261 -0
- data/lib/woods/extractors/callback_analyzer.rb +246 -0
- data/lib/woods/extractors/concern_extractor.rb +292 -0
- data/lib/woods/extractors/configuration_extractor.rb +219 -0
- data/lib/woods/extractors/controller_extractor.rb +404 -0
- data/lib/woods/extractors/database_view_extractor.rb +278 -0
- data/lib/woods/extractors/decorator_extractor.rb +253 -0
- data/lib/woods/extractors/engine_extractor.rb +223 -0
- data/lib/woods/extractors/event_extractor.rb +211 -0
- data/lib/woods/extractors/factory_extractor.rb +289 -0
- data/lib/woods/extractors/graphql_extractor.rb +892 -0
- data/lib/woods/extractors/i18n_extractor.rb +117 -0
- data/lib/woods/extractors/job_extractor.rb +374 -0
- data/lib/woods/extractors/lib_extractor.rb +218 -0
- data/lib/woods/extractors/mailer_extractor.rb +269 -0
- data/lib/woods/extractors/manager_extractor.rb +188 -0
- data/lib/woods/extractors/middleware_extractor.rb +133 -0
- data/lib/woods/extractors/migration_extractor.rb +469 -0
- data/lib/woods/extractors/model_extractor.rb +988 -0
- data/lib/woods/extractors/phlex_extractor.rb +252 -0
- data/lib/woods/extractors/policy_extractor.rb +191 -0
- data/lib/woods/extractors/poro_extractor.rb +229 -0
- data/lib/woods/extractors/pundit_extractor.rb +223 -0
- data/lib/woods/extractors/rails_source_extractor.rb +473 -0
- data/lib/woods/extractors/rake_task_extractor.rb +343 -0
- data/lib/woods/extractors/route_extractor.rb +181 -0
- data/lib/woods/extractors/scheduled_job_extractor.rb +331 -0
- data/lib/woods/extractors/serializer_extractor.rb +339 -0
- data/lib/woods/extractors/service_extractor.rb +217 -0
- data/lib/woods/extractors/shared_dependency_scanner.rb +91 -0
- data/lib/woods/extractors/shared_utility_methods.rb +281 -0
- data/lib/woods/extractors/state_machine_extractor.rb +398 -0
- data/lib/woods/extractors/test_mapping_extractor.rb +225 -0
- data/lib/woods/extractors/validator_extractor.rb +211 -0
- data/lib/woods/extractors/view_component_extractor.rb +311 -0
- data/lib/woods/extractors/view_template_extractor.rb +261 -0
- data/lib/woods/feedback/gap_detector.rb +89 -0
- data/lib/woods/feedback/store.rb +119 -0
- data/lib/woods/filename_utils.rb +32 -0
- data/lib/woods/flow_analysis/operation_extractor.rb +206 -0
- data/lib/woods/flow_analysis/response_code_mapper.rb +154 -0
- data/lib/woods/flow_assembler.rb +290 -0
- data/lib/woods/flow_document.rb +191 -0
- data/lib/woods/flow_precomputer.rb +102 -0
- data/lib/woods/formatting/base.rb +30 -0
- data/lib/woods/formatting/claude_adapter.rb +98 -0
- data/lib/woods/formatting/generic_adapter.rb +56 -0
- data/lib/woods/formatting/gpt_adapter.rb +64 -0
- data/lib/woods/formatting/human_adapter.rb +78 -0
- data/lib/woods/graph_analyzer.rb +374 -0
- data/lib/woods/mcp/bootstrapper.rb +96 -0
- data/lib/woods/mcp/index_reader.rb +394 -0
- data/lib/woods/mcp/renderers/claude_renderer.rb +81 -0
- data/lib/woods/mcp/renderers/json_renderer.rb +17 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +353 -0
- data/lib/woods/mcp/renderers/plain_renderer.rb +240 -0
- data/lib/woods/mcp/server.rb +962 -0
- data/lib/woods/mcp/tool_response_renderer.rb +85 -0
- data/lib/woods/model_name_cache.rb +51 -0
- data/lib/woods/notion/client.rb +217 -0
- data/lib/woods/notion/exporter.rb +219 -0
- data/lib/woods/notion/mapper.rb +40 -0
- data/lib/woods/notion/mappers/column_mapper.rb +57 -0
- data/lib/woods/notion/mappers/migration_mapper.rb +39 -0
- data/lib/woods/notion/mappers/model_mapper.rb +161 -0
- data/lib/woods/notion/mappers/shared.rb +22 -0
- data/lib/woods/notion/rate_limiter.rb +68 -0
- data/lib/woods/observability/health_check.rb +79 -0
- data/lib/woods/observability/instrumentation.rb +34 -0
- data/lib/woods/observability/structured_logger.rb +57 -0
- data/lib/woods/operator/error_escalator.rb +81 -0
- data/lib/woods/operator/pipeline_guard.rb +92 -0
- data/lib/woods/operator/status_reporter.rb +80 -0
- data/lib/woods/railtie.rb +38 -0
- data/lib/woods/resilience/circuit_breaker.rb +99 -0
- data/lib/woods/resilience/index_validator.rb +167 -0
- data/lib/woods/resilience/retryable_provider.rb +108 -0
- data/lib/woods/retrieval/context_assembler.rb +261 -0
- data/lib/woods/retrieval/query_classifier.rb +133 -0
- data/lib/woods/retrieval/ranker.rb +277 -0
- data/lib/woods/retrieval/search_executor.rb +316 -0
- data/lib/woods/retriever.rb +152 -0
- data/lib/woods/ruby_analyzer/class_analyzer.rb +170 -0
- data/lib/woods/ruby_analyzer/dataflow_analyzer.rb +77 -0
- data/lib/woods/ruby_analyzer/fqn_builder.rb +18 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +280 -0
- data/lib/woods/ruby_analyzer/method_analyzer.rb +143 -0
- data/lib/woods/ruby_analyzer/trace_enricher.rb +143 -0
- data/lib/woods/ruby_analyzer.rb +87 -0
- data/lib/woods/session_tracer/file_store.rb +104 -0
- data/lib/woods/session_tracer/middleware.rb +143 -0
- data/lib/woods/session_tracer/redis_store.rb +106 -0
- data/lib/woods/session_tracer/session_flow_assembler.rb +254 -0
- data/lib/woods/session_tracer/session_flow_document.rb +223 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +139 -0
- data/lib/woods/session_tracer/store.rb +81 -0
- data/lib/woods/storage/graph_store.rb +120 -0
- data/lib/woods/storage/metadata_store.rb +196 -0
- data/lib/woods/storage/pgvector.rb +195 -0
- data/lib/woods/storage/qdrant.rb +205 -0
- data/lib/woods/storage/vector_store.rb +167 -0
- data/lib/woods/temporal/json_snapshot_store.rb +245 -0
- data/lib/woods/temporal/snapshot_store.rb +345 -0
- data/lib/woods/token_utils.rb +19 -0
- data/lib/woods/version.rb +5 -0
- data/lib/woods.rb +246 -0
- metadata +270 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ast'
|
|
4
|
+
require_relative 'extracted_unit'
|
|
5
|
+
require_relative 'ruby_analyzer/class_analyzer'
|
|
6
|
+
require_relative 'ruby_analyzer/method_analyzer'
|
|
7
|
+
require_relative 'ruby_analyzer/dataflow_analyzer'
|
|
8
|
+
require_relative 'ruby_analyzer/trace_enricher'
|
|
9
|
+
|
|
10
|
+
module Woods
|
|
11
|
+
# Analyzes plain Ruby source code and produces ExtractedUnit objects.
|
|
12
|
+
#
|
|
13
|
+
# Orchestrates ClassAnalyzer, MethodAnalyzer, DataFlowAnalyzer, and
|
|
14
|
+
# optional TraceEnricher to extract structured data from Ruby files.
|
|
15
|
+
#
|
|
16
|
+
# @example Analyze gem source
|
|
17
|
+
# units = Woods::RubyAnalyzer.analyze(paths: ["lib/"])
|
|
18
|
+
# units.select { |u| u.type == :ruby_class }.map(&:identifier)
|
|
19
|
+
#
|
|
20
|
+
module RubyAnalyzer
|
|
21
|
+
class << self
|
|
22
|
+
# Analyze Ruby source files and produce ExtractedUnit objects.
|
|
23
|
+
#
|
|
24
|
+
# @param paths [Array<String>] File paths or directories to analyze
|
|
25
|
+
# @param trace_data [Array<Hash>, nil] Optional runtime trace data for enrichment
|
|
26
|
+
# @return [Array<ExtractedUnit>] All extracted units
|
|
27
|
+
def analyze(paths:, trace_data: nil)
|
|
28
|
+
files = discover_files(paths)
|
|
29
|
+
return [] if files.empty?
|
|
30
|
+
|
|
31
|
+
parser = Ast::Parser.new
|
|
32
|
+
class_analyzer = ClassAnalyzer.new(parser: parser)
|
|
33
|
+
method_analyzer = MethodAnalyzer.new(parser: parser)
|
|
34
|
+
dataflow_analyzer = DataFlowAnalyzer.new(parser: parser)
|
|
35
|
+
|
|
36
|
+
units = []
|
|
37
|
+
|
|
38
|
+
files.each do |file_path|
|
|
39
|
+
source = read_file(file_path)
|
|
40
|
+
next unless source
|
|
41
|
+
|
|
42
|
+
units.concat(class_analyzer.analyze(source: source, file_path: file_path))
|
|
43
|
+
units.concat(method_analyzer.analyze(source: source, file_path: file_path))
|
|
44
|
+
rescue Woods::ExtractionError
|
|
45
|
+
# Skip files that fail to parse
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
dataflow_analyzer.annotate(units)
|
|
50
|
+
TraceEnricher.merge(units: units, trace_data: trace_data) if trace_data
|
|
51
|
+
|
|
52
|
+
units
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Discover .rb files from a list of paths (files and/or directories).
|
|
58
|
+
#
|
|
59
|
+
# @param paths [Array<String>] File paths or directory paths
|
|
60
|
+
# @return [Array<String>] Absolute paths to .rb files
|
|
61
|
+
def discover_files(paths)
|
|
62
|
+
files = []
|
|
63
|
+
paths.each do |path|
|
|
64
|
+
expanded = File.expand_path(path)
|
|
65
|
+
if File.directory?(expanded)
|
|
66
|
+
Dir.glob(File.join(expanded, '**', '*.rb')).each do |f|
|
|
67
|
+
files << f
|
|
68
|
+
end
|
|
69
|
+
elsif File.file?(expanded) && expanded.end_with?('.rb')
|
|
70
|
+
files << expanded
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
files.uniq
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Read a file safely, returning nil on failure.
|
|
77
|
+
#
|
|
78
|
+
# @param path [String] File path
|
|
79
|
+
# @return [String, nil] File contents or nil
|
|
80
|
+
def read_file(path)
|
|
81
|
+
File.read(path)
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require_relative 'store'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module SessionTracer
|
|
9
|
+
# File-backed session store using JSONL (one JSON object per line).
|
|
10
|
+
#
|
|
11
|
+
# Sessions are stored as individual files in a configurable directory:
|
|
12
|
+
# {base_dir}/{session_id}.jsonl
|
|
13
|
+
#
|
|
14
|
+
# Append-only with file locking for concurrency safety. Zero external dependencies.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# store = FileStore.new(base_dir: "tmp/woods/sessions")
|
|
18
|
+
# store.record("abc123", { controller: "PostsController", action: "create" })
|
|
19
|
+
# store.read("abc123") # => [{ "controller" => "PostsController", ... }]
|
|
20
|
+
#
|
|
21
|
+
class FileStore < Store
|
|
22
|
+
# @param base_dir [String] Directory for session JSONL files
|
|
23
|
+
def initialize(base_dir:)
|
|
24
|
+
super()
|
|
25
|
+
@base_dir = base_dir
|
|
26
|
+
FileUtils.mkdir_p(@base_dir)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Append a request record to a session's JSONL file.
|
|
30
|
+
#
|
|
31
|
+
# Uses file locking (LOCK_EX) for concurrency safety.
|
|
32
|
+
#
|
|
33
|
+
# @param session_id [String] The session identifier
|
|
34
|
+
# @param request_data [Hash] Request metadata to store
|
|
35
|
+
# @return [void]
|
|
36
|
+
def record(session_id, request_data)
|
|
37
|
+
path = session_path(session_id)
|
|
38
|
+
line = "#{JSON.generate(request_data)}\n"
|
|
39
|
+
|
|
40
|
+
File.open(path, 'a') do |f|
|
|
41
|
+
f.flock(File::LOCK_EX)
|
|
42
|
+
f.write(line)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Read all request records for a session, ordered by file line order (timestamp).
|
|
47
|
+
#
|
|
48
|
+
# @param session_id [String] The session identifier
|
|
49
|
+
# @return [Array<Hash>] Request records, oldest first
|
|
50
|
+
def read(session_id)
|
|
51
|
+
path = session_path(session_id)
|
|
52
|
+
return [] unless File.exist?(path)
|
|
53
|
+
|
|
54
|
+
File.readlines(path).filter_map do |line|
|
|
55
|
+
stripped = line.strip
|
|
56
|
+
next if stripped.empty?
|
|
57
|
+
|
|
58
|
+
JSON.parse(stripped)
|
|
59
|
+
rescue JSON::ParserError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# List recent session summaries, sorted by last modification time (newest first).
|
|
65
|
+
#
|
|
66
|
+
# @param limit [Integer] Maximum number of sessions to return
|
|
67
|
+
# @return [Array<Hash>] Session summaries
|
|
68
|
+
def sessions(limit: 20)
|
|
69
|
+
pattern = File.join(@base_dir, '*.jsonl')
|
|
70
|
+
files = Dir.glob(pattern).sort_by { |f| -File.mtime(f).to_f }
|
|
71
|
+
|
|
72
|
+
files.first(limit).map do |file|
|
|
73
|
+
session_id = File.basename(file, '.jsonl')
|
|
74
|
+
session_summary(session_id, read(session_id))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Remove all data for a single session.
|
|
79
|
+
#
|
|
80
|
+
# @param session_id [String] The session identifier
|
|
81
|
+
# @return [void]
|
|
82
|
+
def clear(session_id)
|
|
83
|
+
path = session_path(session_id)
|
|
84
|
+
FileUtils.rm_f(path)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Remove all session data.
|
|
88
|
+
#
|
|
89
|
+
# @return [void]
|
|
90
|
+
def clear_all
|
|
91
|
+
pattern = File.join(@base_dir, '*.jsonl')
|
|
92
|
+
Dir.glob(pattern).each { |f| File.delete(f) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# @param session_id [String]
|
|
98
|
+
# @return [String] Full path to the session's JSONL file
|
|
99
|
+
def session_path(session_id)
|
|
100
|
+
File.join(@base_dir, "#{sanitize_session_id(session_id)}.jsonl")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module SessionTracer
|
|
7
|
+
# Rack middleware that captures request metadata for session tracing.
|
|
8
|
+
#
|
|
9
|
+
# Wraps `@app.call(env)`, records after response. Extracts controller/action
|
|
10
|
+
# from `env['action_dispatch.request.path_parameters']`. Session ID from
|
|
11
|
+
# `X-Trace-Session` header first, falls back to `request.session.id`.
|
|
12
|
+
#
|
|
13
|
+
# Fire-and-forget writes — `rescue StandardError` on recording, never breaks the request.
|
|
14
|
+
#
|
|
15
|
+
# @example Inserting into a Rails middleware stack
|
|
16
|
+
# app.middleware.insert_after ActionDispatch::Session::CookieStore,
|
|
17
|
+
# Woods::SessionTracer::Middleware
|
|
18
|
+
#
|
|
19
|
+
class Middleware
|
|
20
|
+
# @param app [#call] The downstream Rack application
|
|
21
|
+
# @param store [Store] Session trace store backend
|
|
22
|
+
# @param session_id_proc [Proc, nil] Custom session ID extraction (receives env)
|
|
23
|
+
# @param exclude_paths [Array<String>] Path prefixes to skip
|
|
24
|
+
def initialize(app, store:, session_id_proc: nil, exclude_paths: [])
|
|
25
|
+
@app = app
|
|
26
|
+
@store = store
|
|
27
|
+
@session_id_proc = session_id_proc
|
|
28
|
+
@exclude_paths = exclude_paths
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param env [Hash] Rack environment
|
|
32
|
+
# @return [Array] Rack response triple [status, headers, body]
|
|
33
|
+
def call(env)
|
|
34
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
35
|
+
status, headers, body = @app.call(env)
|
|
36
|
+
duration_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - start_time
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
record_request(env, status, duration_ms)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
# Fire-and-forget — recording failures never break the request
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
[status, headers, body]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Record the request metadata to the store.
|
|
50
|
+
#
|
|
51
|
+
# @param env [Hash] Rack environment
|
|
52
|
+
# @param status [Integer] HTTP response status
|
|
53
|
+
# @param duration_ms [Integer] Request duration in milliseconds
|
|
54
|
+
# rubocop:disable Metrics/MethodLength
|
|
55
|
+
def record_request(env, status, duration_ms)
|
|
56
|
+
path = env['PATH_INFO'] || ''
|
|
57
|
+
return if excluded?(path)
|
|
58
|
+
|
|
59
|
+
session_id = extract_session_id(env)
|
|
60
|
+
return unless session_id
|
|
61
|
+
|
|
62
|
+
path_params = env['action_dispatch.request.path_parameters'] || {}
|
|
63
|
+
controller = path_params[:controller]
|
|
64
|
+
action = path_params[:action]
|
|
65
|
+
return unless controller
|
|
66
|
+
|
|
67
|
+
# Classify controller name (e.g., "orders" -> "OrdersController")
|
|
68
|
+
controller_class = classify_controller(controller)
|
|
69
|
+
|
|
70
|
+
request_data = {
|
|
71
|
+
'session_id' => session_id,
|
|
72
|
+
'trace_tag' => env['HTTP_X_TRACE_SESSION'],
|
|
73
|
+
'timestamp' => Time.now.utc.iso8601,
|
|
74
|
+
'method' => env['REQUEST_METHOD'],
|
|
75
|
+
'path' => path,
|
|
76
|
+
'controller' => controller_class,
|
|
77
|
+
'action' => action.to_s,
|
|
78
|
+
'status' => status.to_i,
|
|
79
|
+
'duration_ms' => duration_ms.to_i,
|
|
80
|
+
'format' => extract_format(env)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@store.record(session_id, request_data)
|
|
84
|
+
end
|
|
85
|
+
# rubocop:enable Metrics/MethodLength
|
|
86
|
+
|
|
87
|
+
# Extract session ID: X-Trace-Session header first, then session cookie, then fallback.
|
|
88
|
+
#
|
|
89
|
+
# @param env [Hash] Rack environment
|
|
90
|
+
# @return [String, nil] Session identifier
|
|
91
|
+
def extract_session_id(env)
|
|
92
|
+
return @session_id_proc.call(env) if @session_id_proc
|
|
93
|
+
|
|
94
|
+
# 1. X-Trace-Session header (explicit trace tag doubles as session ID)
|
|
95
|
+
trace_header = env['HTTP_X_TRACE_SESSION']
|
|
96
|
+
return trace_header if trace_header && !trace_header.empty?
|
|
97
|
+
|
|
98
|
+
# 2. Rack session ID
|
|
99
|
+
session = env['rack.session']
|
|
100
|
+
session_id = session&.id || session&.dig('session_id')
|
|
101
|
+
return session_id.to_s if session_id
|
|
102
|
+
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Check if the path should be excluded from tracing.
|
|
107
|
+
#
|
|
108
|
+
# @param path [String] Request path
|
|
109
|
+
# @return [Boolean]
|
|
110
|
+
def excluded?(path)
|
|
111
|
+
@exclude_paths.any? { |prefix| path.start_with?(prefix) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Classify a Rails controller path segment into a controller class name.
|
|
115
|
+
#
|
|
116
|
+
# @param controller [String] e.g., "orders" or "admin/orders"
|
|
117
|
+
# @return [String] e.g., "OrdersController" or "Admin::OrdersController"
|
|
118
|
+
def classify_controller(controller)
|
|
119
|
+
parts = controller.to_s
|
|
120
|
+
.split('/')
|
|
121
|
+
.map { |segment| segment.split('_').map(&:capitalize).join }
|
|
122
|
+
"#{parts.join('::')}Controller"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Extract response format from the Rack env.
|
|
126
|
+
#
|
|
127
|
+
# @param env [Hash] Rack environment
|
|
128
|
+
# @return [String] Format string (e.g., "html", "json")
|
|
129
|
+
def extract_format(env)
|
|
130
|
+
# Check action_dispatch format first
|
|
131
|
+
path_params = env['action_dispatch.request.path_parameters'] || {}
|
|
132
|
+
return path_params[:format].to_s if path_params[:format]
|
|
133
|
+
|
|
134
|
+
# Infer from content type or Accept header
|
|
135
|
+
accept = env['HTTP_ACCEPT'] || ''
|
|
136
|
+
return 'json' if accept.include?('application/json')
|
|
137
|
+
return 'html' if accept.include?('text/html')
|
|
138
|
+
|
|
139
|
+
'html'
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'store'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module SessionTracer
|
|
8
|
+
# Redis-backed session store using Lists.
|
|
9
|
+
#
|
|
10
|
+
# Each session is stored as a Redis List keyed `woods:session:{id}`.
|
|
11
|
+
# RPUSH per request for append-only ordering. Native TTL for automatic cleanup.
|
|
12
|
+
#
|
|
13
|
+
# Requires the `redis` gem at runtime.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# store = RedisStore.new(redis: Redis.new, ttl: 3600)
|
|
17
|
+
# store.record("abc123", { controller: "OrdersController", action: "create" })
|
|
18
|
+
#
|
|
19
|
+
class RedisStore < Store
|
|
20
|
+
KEY_PREFIX = 'woods:session:'
|
|
21
|
+
SESSIONS_KEY = 'woods:sessions'
|
|
22
|
+
|
|
23
|
+
# @param redis [Redis] A Redis client instance
|
|
24
|
+
# @param ttl [Integer, nil] Time-to-live in seconds for session keys (nil = no expiry)
|
|
25
|
+
def initialize(redis:, ttl: nil)
|
|
26
|
+
super()
|
|
27
|
+
unless defined?(::Redis)
|
|
28
|
+
raise SessionTracerError, 'The redis gem is required for RedisStore. Add `gem "redis"` to your Gemfile.'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@redis = redis
|
|
32
|
+
@ttl = ttl
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Append a request record to a session's Redis List.
|
|
36
|
+
#
|
|
37
|
+
# @param session_id [String] The session identifier
|
|
38
|
+
# @param request_data [Hash] Request metadata to store
|
|
39
|
+
# @return [void]
|
|
40
|
+
def record(session_id, request_data)
|
|
41
|
+
key = session_key(session_id)
|
|
42
|
+
@redis.rpush(key, JSON.generate(request_data))
|
|
43
|
+
@redis.expire(key, @ttl) if @ttl
|
|
44
|
+
@redis.sadd(SESSIONS_KEY, session_id)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Read all request records for a session.
|
|
48
|
+
#
|
|
49
|
+
# @param session_id [String] The session identifier
|
|
50
|
+
# @return [Array<Hash>] Request records, oldest first
|
|
51
|
+
def read(session_id)
|
|
52
|
+
key = session_key(session_id)
|
|
53
|
+
@redis.lrange(key, 0, -1).filter_map do |json|
|
|
54
|
+
JSON.parse(json)
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# List recent session summaries.
|
|
61
|
+
#
|
|
62
|
+
# @param limit [Integer] Maximum number of sessions to return
|
|
63
|
+
# @return [Array<Hash>] Session summaries
|
|
64
|
+
def sessions(limit: 20)
|
|
65
|
+
all_ids = @redis.smembers(SESSIONS_KEY)
|
|
66
|
+
|
|
67
|
+
# Filter to sessions that still have data (TTL may have expired)
|
|
68
|
+
active = all_ids.select { |id| @redis.exists?(session_key(id)) }
|
|
69
|
+
|
|
70
|
+
# Remove expired session IDs from the set
|
|
71
|
+
expired = all_ids - active
|
|
72
|
+
expired.each { |id| @redis.srem(SESSIONS_KEY, id) } if expired.any?
|
|
73
|
+
|
|
74
|
+
active.first(limit).map do |session_id|
|
|
75
|
+
session_summary(session_id, read(session_id))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Remove all data for a single session.
|
|
80
|
+
#
|
|
81
|
+
# @param session_id [String] The session identifier
|
|
82
|
+
# @return [void]
|
|
83
|
+
def clear(session_id)
|
|
84
|
+
@redis.del(session_key(session_id))
|
|
85
|
+
@redis.srem(SESSIONS_KEY, session_id)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Remove all session data.
|
|
89
|
+
#
|
|
90
|
+
# @return [void]
|
|
91
|
+
def clear_all
|
|
92
|
+
all_ids = @redis.smembers(SESSIONS_KEY)
|
|
93
|
+
all_ids.each { |id| @redis.del(session_key(id)) }
|
|
94
|
+
@redis.del(SESSIONS_KEY)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# @param session_id [String]
|
|
100
|
+
# @return [String] Redis key for this session
|
|
101
|
+
def session_key(session_id)
|
|
102
|
+
"#{KEY_PREFIX}#{sanitize_session_id(session_id)}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'set'
|
|
5
|
+
require_relative '../token_utils'
|
|
6
|
+
require_relative 'session_flow_document'
|
|
7
|
+
|
|
8
|
+
module Woods
|
|
9
|
+
module SessionTracer
|
|
10
|
+
# Assembles a context tree from captured session requests against the extracted index.
|
|
11
|
+
#
|
|
12
|
+
# Does NOT require Rails — reads from a store + on-disk extracted index.
|
|
13
|
+
#
|
|
14
|
+
# Algorithm:
|
|
15
|
+
# 1. Load requests from store for session_id
|
|
16
|
+
# 2. For each request, resolve "Controller#action" via IndexReader
|
|
17
|
+
# 3. Expand dependencies via DependencyGraph — filter :job/:mailer as async side effects
|
|
18
|
+
# 4. Deduplicate units across steps (include source once, reference by identifier)
|
|
19
|
+
# 5. Token budget allocation with priority-based truncation
|
|
20
|
+
# 6. Build SessionFlowDocument
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# assembler = SessionFlowAssembler.new(store: store, reader: reader)
|
|
24
|
+
# doc = assembler.assemble("abc123", budget: 8000, depth: 1)
|
|
25
|
+
# puts doc.to_context
|
|
26
|
+
#
|
|
27
|
+
# rubocop:disable Metrics/ClassLength
|
|
28
|
+
class SessionFlowAssembler
|
|
29
|
+
ASYNC_TYPES = %w[job mailer].to_set.freeze
|
|
30
|
+
|
|
31
|
+
# @param store [Store] Session trace store
|
|
32
|
+
# @param reader [MCP::IndexReader] Index reader for unit lookups
|
|
33
|
+
def initialize(store:, reader:)
|
|
34
|
+
@store = store
|
|
35
|
+
@reader = reader
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Assemble a context tree for a session.
|
|
39
|
+
#
|
|
40
|
+
# @param session_id [String] The session to assemble
|
|
41
|
+
# @param budget [Integer] Maximum token budget (default: 8000)
|
|
42
|
+
# @param depth [Integer] Expansion depth (0=metadata only, 1=direct deps, 2+=full flow)
|
|
43
|
+
# @return [SessionFlowDocument] The assembled document
|
|
44
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
45
|
+
def assemble(session_id, budget: 8000, depth: 1)
|
|
46
|
+
requests = @store.read(session_id)
|
|
47
|
+
return empty_document(session_id) if requests.empty?
|
|
48
|
+
|
|
49
|
+
steps = []
|
|
50
|
+
context_pool = {}
|
|
51
|
+
side_effects = []
|
|
52
|
+
dependency_map = {}
|
|
53
|
+
seen_units = Set.new
|
|
54
|
+
|
|
55
|
+
requests.each_with_index do |req, idx|
|
|
56
|
+
step = build_step(req, idx)
|
|
57
|
+
steps << step
|
|
58
|
+
|
|
59
|
+
next if depth.zero?
|
|
60
|
+
|
|
61
|
+
controller_id = req['controller']
|
|
62
|
+
next unless controller_id
|
|
63
|
+
|
|
64
|
+
# Resolve controller unit
|
|
65
|
+
unit = @reader.find_unit(controller_id)
|
|
66
|
+
if unit && !seen_units.include?(controller_id)
|
|
67
|
+
seen_units.add(controller_id)
|
|
68
|
+
context_pool[controller_id] = unit_summary(unit)
|
|
69
|
+
end
|
|
70
|
+
step[:unit_refs] = [controller_id].compact
|
|
71
|
+
|
|
72
|
+
# Expand dependencies
|
|
73
|
+
next unless unit
|
|
74
|
+
|
|
75
|
+
deps = resolve_dependencies(controller_id, seen_units, context_pool,
|
|
76
|
+
side_effects, step, dependency_map, depth)
|
|
77
|
+
step[:unit_refs].concat(deps)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Apply token budget
|
|
81
|
+
token_count = apply_budget(context_pool, budget)
|
|
82
|
+
|
|
83
|
+
SessionFlowDocument.new(
|
|
84
|
+
session_id: session_id,
|
|
85
|
+
steps: steps,
|
|
86
|
+
context_pool: context_pool,
|
|
87
|
+
side_effects: side_effects,
|
|
88
|
+
dependency_map: dependency_map,
|
|
89
|
+
token_count: token_count
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Build a timeline step from a request record.
|
|
97
|
+
#
|
|
98
|
+
# @param req [Hash] Request data from store
|
|
99
|
+
# @param index [Integer] Step index
|
|
100
|
+
# @return [Hash] Step hash
|
|
101
|
+
def build_step(req, index)
|
|
102
|
+
{
|
|
103
|
+
index: index,
|
|
104
|
+
method: req['method'],
|
|
105
|
+
path: req['path'],
|
|
106
|
+
controller: req['controller'],
|
|
107
|
+
action: req['action'],
|
|
108
|
+
status: req['status'],
|
|
109
|
+
duration_ms: req['duration_ms'],
|
|
110
|
+
unit_refs: [],
|
|
111
|
+
side_effects: []
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Resolve dependencies for a unit, separating sync deps from async side effects.
|
|
116
|
+
#
|
|
117
|
+
# @return [Array<String>] Non-async dependency identifiers added
|
|
118
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
119
|
+
def resolve_dependencies(unit_id, seen_units, context_pool,
|
|
120
|
+
side_effects, step, dependency_map, depth)
|
|
121
|
+
graph = @reader.dependency_graph
|
|
122
|
+
dep_ids = graph.dependencies_of(unit_id)
|
|
123
|
+
added = []
|
|
124
|
+
|
|
125
|
+
dep_ids.each do |dep_id|
|
|
126
|
+
dep_unit = @reader.find_unit(dep_id)
|
|
127
|
+
next unless dep_unit
|
|
128
|
+
|
|
129
|
+
dep_type = dep_unit['type']&.to_s
|
|
130
|
+
|
|
131
|
+
if ASYNC_TYPES.include?(dep_type)
|
|
132
|
+
effect = {
|
|
133
|
+
type: dep_type.to_sym,
|
|
134
|
+
identifier: dep_id,
|
|
135
|
+
trigger_step: "#{step[:controller]}##{step[:action]}"
|
|
136
|
+
}
|
|
137
|
+
side_effects << effect
|
|
138
|
+
step[:side_effects] << effect
|
|
139
|
+
else
|
|
140
|
+
unless seen_units.include?(dep_id)
|
|
141
|
+
seen_units.add(dep_id)
|
|
142
|
+
context_pool[dep_id] = unit_summary(dep_unit)
|
|
143
|
+
added << dep_id
|
|
144
|
+
|
|
145
|
+
# Depth 2+: expand transitive dependencies
|
|
146
|
+
expand_transitive(dep_id, seen_units, context_pool, dependency_map, depth - 1) if depth >= 2
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Record dependency map for this unit
|
|
152
|
+
all_deps = dep_ids.select { |id| @reader.find_unit(id) }
|
|
153
|
+
dependency_map[unit_id] = all_deps if all_deps.any?
|
|
154
|
+
|
|
155
|
+
added
|
|
156
|
+
end
|
|
157
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
158
|
+
|
|
159
|
+
# Expand transitive dependencies (depth 2+).
|
|
160
|
+
#
|
|
161
|
+
# @param unit_id [String] Unit to expand from
|
|
162
|
+
# @param seen_units [Set<String>] Already-seen unit identifiers
|
|
163
|
+
# @param context_pool [Hash] Accumulator for unit data
|
|
164
|
+
# @param dependency_map [Hash] Accumulator for dependency edges
|
|
165
|
+
# @param remaining_depth [Integer] Remaining expansion depth
|
|
166
|
+
def expand_transitive(unit_id, seen_units, context_pool, dependency_map, remaining_depth)
|
|
167
|
+
return if remaining_depth <= 0
|
|
168
|
+
|
|
169
|
+
graph = @reader.dependency_graph
|
|
170
|
+
dep_ids = graph.dependencies_of(unit_id)
|
|
171
|
+
resolved_deps = []
|
|
172
|
+
|
|
173
|
+
dep_ids.each do |dep_id|
|
|
174
|
+
dep_unit = @reader.find_unit(dep_id)
|
|
175
|
+
next unless dep_unit
|
|
176
|
+
|
|
177
|
+
resolved_deps << dep_id
|
|
178
|
+
next if seen_units.include?(dep_id)
|
|
179
|
+
|
|
180
|
+
seen_units.add(dep_id)
|
|
181
|
+
context_pool[dep_id] = unit_summary(dep_unit)
|
|
182
|
+
|
|
183
|
+
expand_transitive(dep_id, seen_units, context_pool, dependency_map, remaining_depth - 1)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
dependency_map[unit_id] = resolved_deps if resolved_deps.any?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Extract a summary hash from a full unit data hash.
|
|
190
|
+
#
|
|
191
|
+
# @param unit [Hash] Full unit data from IndexReader
|
|
192
|
+
# @return [Hash] Summary with :type, :file_path, :source_code
|
|
193
|
+
def unit_summary(unit)
|
|
194
|
+
{
|
|
195
|
+
type: unit['type'],
|
|
196
|
+
file_path: unit['file_path'],
|
|
197
|
+
source_code: unit['source_code']
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Apply token budget by truncating source code from lowest-priority units.
|
|
202
|
+
#
|
|
203
|
+
# Priority order (highest first):
|
|
204
|
+
# 1. Controller action chunks (directly hit by requests)
|
|
205
|
+
# 2. Direct dependencies (models, services)
|
|
206
|
+
# 3. Transitive dependencies
|
|
207
|
+
#
|
|
208
|
+
# @param context_pool [Hash] Unit data to budget
|
|
209
|
+
# @param budget [Integer] Maximum tokens
|
|
210
|
+
# @return [Integer] Actual token count
|
|
211
|
+
def apply_budget(context_pool, budget)
|
|
212
|
+
total = estimate_tokens(context_pool)
|
|
213
|
+
return total if total <= budget
|
|
214
|
+
|
|
215
|
+
# Truncate from the end (lowest priority = last added)
|
|
216
|
+
identifiers = context_pool.keys.reverse
|
|
217
|
+
identifiers.each do |id|
|
|
218
|
+
break if total <= budget
|
|
219
|
+
|
|
220
|
+
unit = context_pool[id]
|
|
221
|
+
source = unit[:source_code]
|
|
222
|
+
next unless source
|
|
223
|
+
|
|
224
|
+
source_tokens = TokenUtils.estimate_tokens(source)
|
|
225
|
+
unit[:source_code] = "# source truncated (#{source_tokens} tokens)"
|
|
226
|
+
total -= source_tokens
|
|
227
|
+
total += TokenUtils.estimate_tokens(unit[:source_code])
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
[total, 0].max
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Estimate total tokens for the context pool.
|
|
234
|
+
#
|
|
235
|
+
# @param context_pool [Hash] Unit data
|
|
236
|
+
# @return [Integer] Estimated token count
|
|
237
|
+
def estimate_tokens(context_pool)
|
|
238
|
+
context_pool.values.sum do |unit|
|
|
239
|
+
source = unit[:source_code] || ''
|
|
240
|
+
TokenUtils.estimate_tokens(source) + 20 # overhead for tags/metadata
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Build an empty document for sessions with no requests.
|
|
245
|
+
#
|
|
246
|
+
# @param session_id [String]
|
|
247
|
+
# @return [SessionFlowDocument]
|
|
248
|
+
def empty_document(session_id)
|
|
249
|
+
SessionFlowDocument.new(session_id: session_id)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
# rubocop:enable Metrics/ClassLength
|
|
253
|
+
end
|
|
254
|
+
end
|