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
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'time'
|
|
5
5
|
require 'digest'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
require_relative '../atomic_file'
|
|
8
|
+
require_relative '../payload_store'
|
|
6
9
|
|
|
7
10
|
module Woods
|
|
8
11
|
module Temporal
|
|
@@ -12,8 +15,16 @@ module Woods
|
|
|
12
15
|
# of the index output directory. Each file is named by git SHA and contains
|
|
13
16
|
# manifest metadata plus per-unit content hashes.
|
|
14
17
|
#
|
|
18
|
+
# Retention matches the payload store (audit P8): capture prunes the
|
|
19
|
+
# oldest snapshots beyond the configured count, keyed off the same
|
|
20
|
+
# WOODS_PAYLOAD_RETENTION env var. Old histories answering `diff` and
|
|
21
|
+
# `unit_history` beyond the retention window degrade to empty.
|
|
22
|
+
#
|
|
15
23
|
# Implements the same public interface as SnapshotStore so the MCP server
|
|
16
24
|
# tools work identically.
|
|
25
|
+
# Malformed retained snapshots are warned about and treated as absent:
|
|
26
|
+
# +find+ returns nil, +diff+ returns an empty result, and history/list scans
|
|
27
|
+
# omit the corrupt file.
|
|
17
28
|
#
|
|
18
29
|
# @example
|
|
19
30
|
# store = JsonSnapshotStore.new(dir: '/app/tmp/woods')
|
|
@@ -22,8 +33,9 @@ module Woods
|
|
|
22
33
|
# store.diff("abc123", "def456") # => { added: [...], modified: [...], deleted: [...] }
|
|
23
34
|
#
|
|
24
35
|
class JsonSnapshotStore # rubocop:disable Metrics/ClassLength
|
|
25
|
-
def initialize(dir:)
|
|
36
|
+
def initialize(dir:, retention: nil)
|
|
26
37
|
@dir = File.join(dir, 'snapshots')
|
|
38
|
+
@retention = resolve_retention(retention)
|
|
27
39
|
FileUtils.mkdir_p(@dir)
|
|
28
40
|
end
|
|
29
41
|
|
|
@@ -34,7 +46,11 @@ module Woods
|
|
|
34
46
|
# reach snapshot_path, which raises ArgumentError on a non-hex SHA.
|
|
35
47
|
return nil unless git_sha.is_a?(String) && git_sha.match?(/\A[0-9a-f]+\z/i)
|
|
36
48
|
|
|
37
|
-
|
|
49
|
+
# Exclude the SHA being captured, mirroring the SQLite store: a
|
|
50
|
+
# re-capture at an unchanged HEAD must diff against the latest
|
|
51
|
+
# distinct snapshot, not whatever this SHA's own file used to hold
|
|
52
|
+
# (L20).
|
|
53
|
+
previous = find_latest(exclude_sha: git_sha)
|
|
38
54
|
snapshot = build_snapshot(manifest, git_sha, unit_hashes)
|
|
39
55
|
|
|
40
56
|
if previous
|
|
@@ -45,6 +61,7 @@ module Woods
|
|
|
45
61
|
end
|
|
46
62
|
|
|
47
63
|
write_snapshot(git_sha, snapshot)
|
|
64
|
+
prune_snapshots(protect: git_sha)
|
|
48
65
|
snapshot.except(:units)
|
|
49
66
|
end
|
|
50
67
|
|
|
@@ -58,7 +75,9 @@ module Woods
|
|
|
58
75
|
path = snapshot_path(git_sha)
|
|
59
76
|
return nil unless File.exist?(path)
|
|
60
77
|
|
|
61
|
-
data =
|
|
78
|
+
data = read_snapshot(path)
|
|
79
|
+
return nil unless data
|
|
80
|
+
|
|
62
81
|
symbolize_snapshot(data).except(:units)
|
|
63
82
|
end
|
|
64
83
|
|
|
@@ -77,26 +96,69 @@ module Woods
|
|
|
77
96
|
.reverse
|
|
78
97
|
.first(limit)
|
|
79
98
|
|
|
80
|
-
entries = snapshots.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
git_branch: snap[:git_branch],
|
|
88
|
-
unit_type: unit[:unit_type],
|
|
89
|
-
source_hash: unit[:source_hash],
|
|
90
|
-
metadata_hash: unit[:metadata_hash],
|
|
91
|
-
dependencies_hash: unit[:dependencies_hash]
|
|
92
|
-
}
|
|
93
|
-
end
|
|
99
|
+
entries = snapshots.flat_map do |snap|
|
|
100
|
+
snap[:units].values.select { |unit| unit[:identifier] == identifier }.map do |unit|
|
|
101
|
+
unit.except(:identifier).merge(
|
|
102
|
+
git_sha: snap[:git_sha], extracted_at: snap[:extracted_at], git_branch: snap[:git_branch]
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end.first(limit)
|
|
94
106
|
|
|
95
107
|
mark_changed_entries(entries)
|
|
96
108
|
end
|
|
97
109
|
|
|
98
110
|
private
|
|
99
111
|
|
|
112
|
+
# Resolve the retention count: an explicit positive value wins, then
|
|
113
|
+
# the WOODS_PAYLOAD_RETENTION env var the payload store honors, then
|
|
114
|
+
# the payload store's default.
|
|
115
|
+
#
|
|
116
|
+
# @param retention [Integer, nil]
|
|
117
|
+
# @return [Integer]
|
|
118
|
+
def resolve_retention(retention)
|
|
119
|
+
return retention if retention.is_a?(Integer) && retention.positive?
|
|
120
|
+
|
|
121
|
+
value = ENV.fetch('WOODS_PAYLOAD_RETENTION', nil).to_i
|
|
122
|
+
value.positive? ? value : PayloadStore::DEFAULT_RETENTION
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Delete snapshots beyond the retention count, oldest by extracted_at
|
|
126
|
+
# first. +protect+ names the snapshot just captured; it is the newest
|
|
127
|
+
# anyway, and a tie on extracted_at must not delete it.
|
|
128
|
+
#
|
|
129
|
+
# Failures are non-fatal: retention is housekeeping, and a failed
|
|
130
|
+
# prune leaves the store growing as it did before rather than
|
|
131
|
+
# breaking capture.
|
|
132
|
+
#
|
|
133
|
+
# @param protect [String] git SHA of the just-captured snapshot
|
|
134
|
+
# @return [void]
|
|
135
|
+
def prune_snapshots(protect:)
|
|
136
|
+
summaries = load_all_summaries
|
|
137
|
+
overflow = summaries.size - @retention
|
|
138
|
+
return unless overflow.positive?
|
|
139
|
+
|
|
140
|
+
retention_victims(summaries, overflow, protect).each { |sha| FileUtils.rm_f(snapshot_path(sha)) }
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
warn "[Woods] Snapshot retention failed: #{e.message}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Oldest `overflow` snapshots by extracted_at, never the protected one.
|
|
146
|
+
# The protected SHA is rejected before sorting and slicing: it is
|
|
147
|
+
# captured last, so its extracted_at can tie or precede older entries,
|
|
148
|
+
# and letting it occupy the victim slice would leave the store one file
|
|
149
|
+
# over its bound with no replacement victim selected.
|
|
150
|
+
#
|
|
151
|
+
# @param summaries [Array<Hash>]
|
|
152
|
+
# @param overflow [Integer]
|
|
153
|
+
# @param protect [String] git SHA of the just-captured snapshot
|
|
154
|
+
# @return [Array<String>]
|
|
155
|
+
def retention_victims(summaries, overflow, protect)
|
|
156
|
+
summaries.reject { |summary| summary[:git_sha] == protect }
|
|
157
|
+
.sort_by { |summary| summary[:extracted_at] || '' }
|
|
158
|
+
.first(overflow)
|
|
159
|
+
.map { |summary| summary[:git_sha] }
|
|
160
|
+
end
|
|
161
|
+
|
|
100
162
|
def mget(hash, key)
|
|
101
163
|
hash[key] || hash[key.to_sym]
|
|
102
164
|
end
|
|
@@ -124,7 +186,8 @@ module Woods
|
|
|
124
186
|
id = mget(uh, 'identifier')
|
|
125
187
|
next if id.nil?
|
|
126
188
|
|
|
127
|
-
[id, {
|
|
189
|
+
[JSON.generate([id, mget(uh, 'type').to_s]), {
|
|
190
|
+
identifier: id,
|
|
128
191
|
unit_type: mget(uh, 'type').to_s,
|
|
129
192
|
source_hash: mget(uh, 'source_hash'),
|
|
130
193
|
metadata_hash: mget(uh, 'metadata_hash'),
|
|
@@ -144,27 +207,26 @@ module Woods
|
|
|
144
207
|
if data_a[:source_hash] != data_b[:source_hash] ||
|
|
145
208
|
data_a[:metadata_hash] != data_b[:metadata_hash] ||
|
|
146
209
|
data_a[:dependencies_hash] != data_b[:dependencies_hash]
|
|
147
|
-
modified << { identifier: identifier, unit_type: data_b[:unit_type] }
|
|
210
|
+
modified << { identifier: data_b[:identifier], unit_type: data_b[:unit_type] }
|
|
148
211
|
end
|
|
149
212
|
else
|
|
150
|
-
added << { identifier: identifier, unit_type: data_b[:unit_type] }
|
|
213
|
+
added << { identifier: data_b[:identifier], unit_type: data_b[:unit_type] }
|
|
151
214
|
end
|
|
152
215
|
end
|
|
153
216
|
|
|
154
217
|
units_a.each do |identifier, data_a|
|
|
155
|
-
deleted << { identifier: identifier, unit_type: data_a[:unit_type] } unless units_b.key?(identifier)
|
|
218
|
+
deleted << { identifier: data_a[:identifier], unit_type: data_a[:unit_type] } unless units_b.key?(identifier)
|
|
156
219
|
end
|
|
157
220
|
|
|
158
221
|
{ added: added, modified: modified, deleted: deleted }
|
|
159
222
|
end
|
|
160
223
|
|
|
161
224
|
def mark_changed_entries(entries)
|
|
162
|
-
entries.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
end
|
|
225
|
+
entries.group_by { |entry| entry[:unit_type] }.each_value do |versions|
|
|
226
|
+
versions.each_with_index do |entry, index|
|
|
227
|
+
older = versions[index + 1]
|
|
228
|
+
entry[:changed] = older.nil? || entry[:source_hash] != older[:source_hash]
|
|
229
|
+
end
|
|
168
230
|
end
|
|
169
231
|
entries
|
|
170
232
|
end
|
|
@@ -176,37 +238,43 @@ module Woods
|
|
|
176
238
|
end
|
|
177
239
|
|
|
178
240
|
def write_snapshot(git_sha, data)
|
|
179
|
-
|
|
241
|
+
AtomicFile.write(snapshot_path(git_sha), JSON.pretty_generate(data))
|
|
180
242
|
end
|
|
181
243
|
|
|
182
244
|
def load_snapshot_with_units(git_sha)
|
|
183
245
|
path = snapshot_path(git_sha)
|
|
184
246
|
return nil unless File.exist?(path)
|
|
185
247
|
|
|
186
|
-
|
|
248
|
+
data = read_snapshot(path)
|
|
249
|
+
data && symbolize_snapshot(data)
|
|
187
250
|
end
|
|
188
251
|
|
|
189
252
|
def load_all_summaries
|
|
190
253
|
Dir.glob(File.join(@dir, '*.json')).filter_map do |path|
|
|
191
|
-
data =
|
|
192
|
-
symbolize_snapshot(data).except(:units)
|
|
193
|
-
rescue JSON::ParserError => e
|
|
194
|
-
warn "[Woods] Skipping corrupt snapshot #{File.basename(path)}: #{e.message}"
|
|
195
|
-
nil
|
|
254
|
+
data = read_snapshot(path)
|
|
255
|
+
symbolize_snapshot(data).except(:units) if data
|
|
196
256
|
end
|
|
197
257
|
end
|
|
198
258
|
|
|
199
259
|
def load_all_with_units
|
|
200
260
|
Dir.glob(File.join(@dir, '*.json')).filter_map do |path|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
warn "[Woods] Skipping corrupt snapshot #{File.basename(path)}: #{e.message}"
|
|
204
|
-
nil
|
|
261
|
+
data = read_snapshot(path)
|
|
262
|
+
symbolize_snapshot(data) if data
|
|
205
263
|
end
|
|
206
264
|
end
|
|
207
265
|
|
|
208
|
-
def
|
|
266
|
+
def read_snapshot(path)
|
|
267
|
+
JSON.parse(AtomicFile.read(path))
|
|
268
|
+
rescue JSON::ParserError => e
|
|
269
|
+
warn "[Woods] Skipping corrupt snapshot #{File.basename(path)}: #{e.message}"
|
|
270
|
+
nil
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# @param exclude_sha [String, nil] SHA to leave out of the result
|
|
274
|
+
# @return [Hash, nil]
|
|
275
|
+
def find_latest(exclude_sha: nil)
|
|
209
276
|
snapshots = load_all_summaries
|
|
277
|
+
snapshots.reject! { |s| s[:git_sha] == exclude_sha } if exclude_sha
|
|
210
278
|
return nil if snapshots.empty?
|
|
211
279
|
|
|
212
280
|
latest = snapshots.max_by { |s| s[:extracted_at] || '' }
|
|
@@ -234,13 +302,15 @@ module Woods
|
|
|
234
302
|
def symbolize_units(units)
|
|
235
303
|
return {} unless units
|
|
236
304
|
|
|
237
|
-
units.
|
|
238
|
-
|
|
305
|
+
units.to_h do |key, v|
|
|
306
|
+
identifier = v['identifier'] || key
|
|
307
|
+
[JSON.generate([identifier, v['unit_type']]), {
|
|
308
|
+
identifier: identifier,
|
|
239
309
|
unit_type: v['unit_type'],
|
|
240
310
|
source_hash: v['source_hash'],
|
|
241
311
|
metadata_hash: v['metadata_hash'],
|
|
242
312
|
dependencies_hash: v['dependencies_hash']
|
|
243
|
-
}
|
|
313
|
+
}]
|
|
244
314
|
end
|
|
245
315
|
end
|
|
246
316
|
end
|
|
@@ -29,6 +29,7 @@ module Woods
|
|
|
29
29
|
# construct the store with a bare mock.
|
|
30
30
|
def initialize(connection:, validate_schema: true)
|
|
31
31
|
@db = connection
|
|
32
|
+
@db.busy_timeout = 5_000 if @db.respond_to?(:busy_timeout=)
|
|
32
33
|
validate_schema! if validate_schema
|
|
33
34
|
end
|
|
34
35
|
|
|
@@ -69,8 +70,10 @@ module Woods
|
|
|
69
70
|
def schema_error_message(detail)
|
|
70
71
|
'SnapshotStore requires the `woods_snapshots` and ' \
|
|
71
72
|
'`woods_snapshot_units` tables (migrations 004 + 005 under ' \
|
|
72
|
-
'`lib/woods/db/migrations/`).
|
|
73
|
-
|
|
73
|
+
'`lib/woods/db/migrations/`). These run automatically on woods-mcp ' \
|
|
74
|
+
'boot (Bootstrapper.build_snapshot_store auto-migrates), or run them ' \
|
|
75
|
+
'directly with `Woods::Db::Migrator.new(connection: db).migrate!`. ' \
|
|
76
|
+
"Underlying error: #{detail}"
|
|
74
77
|
end
|
|
75
78
|
|
|
76
79
|
public
|
|
@@ -80,6 +83,13 @@ module Woods
|
|
|
80
83
|
# Stores the manifest metadata and per-unit content hashes.
|
|
81
84
|
# Computes diff stats vs. the most recent previous snapshot.
|
|
82
85
|
#
|
|
86
|
+
# Re-capturing at an unchanged HEAD updates the snapshot row **in
|
|
87
|
+
# place** — the row id is stable across captures (#206), so the
|
|
88
|
+
# per-unit DELETE below genuinely clears the previous capture's unit
|
|
89
|
+
# set instead of leaking it. Unit rows orphaned by the pre-#206
|
|
90
|
+
# INSERT OR REPLACE (which minted a fresh id per capture) are swept
|
|
91
|
+
# on every capture so historical leaks self-heal.
|
|
92
|
+
#
|
|
83
93
|
# @param manifest [Hash] The manifest data (string or symbol keys)
|
|
84
94
|
# @param unit_hashes [Array<Hash>] Per-unit content hashes
|
|
85
95
|
# @return [Hash] Snapshot record with diff stats
|
|
@@ -90,15 +100,53 @@ module Woods
|
|
|
90
100
|
# collide a snapshot row. Mirrors JsonSnapshotStore's path validation.
|
|
91
101
|
return nil unless git_sha.is_a?(String) && git_sha.match?(/\A[0-9a-f]+\z/i)
|
|
92
102
|
|
|
93
|
-
|
|
94
|
-
|
|
103
|
+
captured = nil
|
|
104
|
+
with_lock_retry do
|
|
105
|
+
@db.transaction(:immediate) do
|
|
106
|
+
# Exclude the SHA being captured: find_latest before the upsert
|
|
107
|
+
# otherwise resolves "previous" to this very row, and
|
|
108
|
+
# update_diff_stats then compares the freshly written unit set
|
|
109
|
+
# against itself — every stat zeroes out silently (L20).
|
|
110
|
+
previous = find_latest(exclude_sha: git_sha)
|
|
111
|
+
upsert_snapshot(manifest, git_sha, unit_hashes.size)
|
|
112
|
+
|
|
113
|
+
snapshot_id = fetch_snapshot_id(git_sha)
|
|
114
|
+
@db.execute('DELETE FROM woods_snapshot_units WHERE snapshot_id = ?', [snapshot_id])
|
|
115
|
+
prune_orphaned_units
|
|
116
|
+
insert_unit_hashes(snapshot_id, unit_hashes)
|
|
117
|
+
|
|
118
|
+
update_diff_stats(snapshot_id, previous)
|
|
119
|
+
captured = find(git_sha)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
captured
|
|
123
|
+
end
|
|
95
124
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
125
|
+
# Attempts for {#with_lock_retry}, including the first.
|
|
126
|
+
LOCK_ATTEMPTS = 3
|
|
127
|
+
|
|
128
|
+
# SQLite skips the busy handler when it detects a lock-order deadlock
|
|
129
|
+
# (this connection holds SHARED and asks for RESERVED while another
|
|
130
|
+
# already holds RESERVED), so `BEGIN IMMEDIATE` can raise "database is
|
|
131
|
+
# locked" at once despite busy_timeout. Two extractions capturing into
|
|
132
|
+
# one metadata database hit that on CI. A short bounded retry lets the
|
|
133
|
+
# other writer finish; anything else, or a non-SQLite connection,
|
|
134
|
+
# raises straight through.
|
|
135
|
+
def with_lock_retry
|
|
136
|
+
attempt = 0
|
|
137
|
+
begin
|
|
138
|
+
attempt += 1
|
|
139
|
+
yield
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
raise unless sqlite_busy?(e) && attempt < LOCK_ATTEMPTS
|
|
142
|
+
|
|
143
|
+
sleep(0.05 * attempt)
|
|
144
|
+
retry
|
|
145
|
+
end
|
|
146
|
+
end
|
|
99
147
|
|
|
100
|
-
|
|
101
|
-
|
|
148
|
+
def sqlite_busy?(error)
|
|
149
|
+
defined?(SQLite3::BusyException) && error.is_a?(SQLite3::BusyException)
|
|
102
150
|
end
|
|
103
151
|
|
|
104
152
|
# List snapshots, optionally filtered by branch.
|
|
@@ -193,12 +241,11 @@ module Woods
|
|
|
193
241
|
# @param entries [Array<Hash>]
|
|
194
242
|
# @return [Array<Hash>]
|
|
195
243
|
def mark_changed_entries(entries)
|
|
196
|
-
entries.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
end
|
|
244
|
+
entries.group_by { |entry| entry[:unit_type] }.each_value do |versions|
|
|
245
|
+
versions.each_with_index do |entry, index|
|
|
246
|
+
older = versions[index + 1]
|
|
247
|
+
entry[:changed] = older.nil? || entry[:source_hash] != older[:source_hash]
|
|
248
|
+
end
|
|
202
249
|
end
|
|
203
250
|
entries
|
|
204
251
|
end
|
|
@@ -212,7 +259,37 @@ module Woods
|
|
|
212
259
|
hash[key] || hash[key.to_sym]
|
|
213
260
|
end
|
|
214
261
|
|
|
215
|
-
#
|
|
262
|
+
# Upsert SQL for the snapshot row, keyed on UNIQUE(git_sha).
|
|
263
|
+
#
|
|
264
|
+
# Deliberately `ON CONFLICT ... DO UPDATE` (SQLite >= 3.24 — the same
|
|
265
|
+
# syntax `Storage::MetadataStore` already relies on), **not**
|
|
266
|
+
# `INSERT OR REPLACE`: REPLACE resolves the conflict by deleting the
|
|
267
|
+
# old row and inserting a fresh one with a new AUTOINCREMENT id, so
|
|
268
|
+
# {#capture}'s subsequent `DELETE ... WHERE snapshot_id = ?` matched
|
|
269
|
+
# nothing and every re-extraction at an unchanged HEAD leaked a full
|
|
270
|
+
# unit-hash set — unboundedly, since foreign keys are never enabled on
|
|
271
|
+
# this connection (#206).
|
|
272
|
+
UPSERT_SNAPSHOT_SQL = <<~SQL
|
|
273
|
+
INSERT INTO woods_snapshots
|
|
274
|
+
(git_sha, git_branch, extracted_at, rails_version, ruby_version,
|
|
275
|
+
total_units, unit_counts, gemfile_lock_sha, schema_sha)
|
|
276
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
277
|
+
ON CONFLICT(git_sha) DO UPDATE SET
|
|
278
|
+
git_branch = excluded.git_branch,
|
|
279
|
+
extracted_at = excluded.extracted_at,
|
|
280
|
+
rails_version = excluded.rails_version,
|
|
281
|
+
ruby_version = excluded.ruby_version,
|
|
282
|
+
total_units = excluded.total_units,
|
|
283
|
+
unit_counts = excluded.unit_counts,
|
|
284
|
+
gemfile_lock_sha = excluded.gemfile_lock_sha,
|
|
285
|
+
schema_sha = excluded.schema_sha
|
|
286
|
+
SQL
|
|
287
|
+
|
|
288
|
+
private_constant :UPSERT_SNAPSHOT_SQL
|
|
289
|
+
|
|
290
|
+
# Insert the snapshot row, or update it in place when this git SHA was
|
|
291
|
+
# captured before. The row id is preserved either way (see
|
|
292
|
+
# {UPSERT_SNAPSHOT_SQL}).
|
|
216
293
|
#
|
|
217
294
|
# @param manifest [Hash]
|
|
218
295
|
# @param git_sha [String]
|
|
@@ -230,11 +307,22 @@ module Woods
|
|
|
230
307
|
mget(manifest, 'gemfile_lock_sha'),
|
|
231
308
|
mget(manifest, 'schema_sha')
|
|
232
309
|
]
|
|
233
|
-
@db.execute(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
310
|
+
@db.execute(UPSERT_SNAPSHOT_SQL, params)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Delete unit rows whose snapshot row no longer exists.
|
|
314
|
+
#
|
|
315
|
+
# Before #206, every re-capture of an unchanged SHA stranded the
|
|
316
|
+
# previous capture's unit rows under a snapshot id REPLACE had deleted
|
|
317
|
+
# (foreign keys are never enabled on this connection, so nothing else
|
|
318
|
+
# cleans them up). One cheap sweep per capture lets databases that
|
|
319
|
+
# accumulated those leaks self-heal.
|
|
320
|
+
#
|
|
321
|
+
# @return [void]
|
|
322
|
+
def prune_orphaned_units
|
|
323
|
+
@db.execute(<<~SQL)
|
|
324
|
+
DELETE FROM woods_snapshot_units
|
|
325
|
+
WHERE snapshot_id NOT IN (SELECT id FROM woods_snapshots)
|
|
238
326
|
SQL
|
|
239
327
|
end
|
|
240
328
|
|
|
@@ -251,11 +339,23 @@ module Woods
|
|
|
251
339
|
)
|
|
252
340
|
end
|
|
253
341
|
|
|
254
|
-
# Find the most recent snapshot.
|
|
342
|
+
# Find the most recent snapshot, optionally excluding one SHA.
|
|
255
343
|
#
|
|
344
|
+
# {#capture} always excludes the SHA it is about to upsert — see the
|
|
345
|
+
# note at the call site (L20).
|
|
346
|
+
#
|
|
347
|
+
# @param exclude_sha [String, nil] SHA to leave out of the result
|
|
256
348
|
# @return [Hash, nil]
|
|
257
|
-
def find_latest
|
|
258
|
-
row =
|
|
349
|
+
def find_latest(exclude_sha: nil)
|
|
350
|
+
row =
|
|
351
|
+
if exclude_sha
|
|
352
|
+
@db.get_first_row(
|
|
353
|
+
'SELECT * FROM woods_snapshots WHERE git_sha != ? ORDER BY extracted_at DESC LIMIT 1',
|
|
354
|
+
[exclude_sha]
|
|
355
|
+
)
|
|
356
|
+
else
|
|
357
|
+
@db.get_first_row('SELECT * FROM woods_snapshots ORDER BY extracted_at DESC LIMIT 1')
|
|
358
|
+
end
|
|
259
359
|
return nil unless row
|
|
260
360
|
|
|
261
361
|
row_to_hash(row)
|
|
@@ -281,20 +381,16 @@ module Woods
|
|
|
281
381
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
282
382
|
SQL
|
|
283
383
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
mget(uh, 'dependencies_hash')
|
|
295
|
-
]
|
|
296
|
-
@db.execute(sql, params)
|
|
297
|
-
end
|
|
384
|
+
unit_hashes.each do |uh|
|
|
385
|
+
params = [
|
|
386
|
+
snapshot_id,
|
|
387
|
+
mget(uh, 'identifier'),
|
|
388
|
+
mget(uh, 'type').to_s,
|
|
389
|
+
mget(uh, 'source_hash'),
|
|
390
|
+
mget(uh, 'metadata_hash'),
|
|
391
|
+
mget(uh, 'dependencies_hash')
|
|
392
|
+
]
|
|
393
|
+
@db.execute(sql, params)
|
|
298
394
|
end
|
|
299
395
|
end
|
|
300
396
|
|
|
@@ -310,7 +406,8 @@ module Woods
|
|
|
310
406
|
rows = @db.execute(sql, [snapshot_id])
|
|
311
407
|
|
|
312
408
|
rows.to_h do |row|
|
|
313
|
-
[row['identifier'], {
|
|
409
|
+
[[row['identifier'], row['unit_type']], {
|
|
410
|
+
identifier: row['identifier'],
|
|
314
411
|
unit_type: row['unit_type'],
|
|
315
412
|
source_hash: row['source_hash'],
|
|
316
413
|
metadata_hash: row['metadata_hash'],
|
|
@@ -337,16 +434,16 @@ module Woods
|
|
|
337
434
|
if data_a[:source_hash] != data_b[:source_hash] ||
|
|
338
435
|
data_a[:metadata_hash] != data_b[:metadata_hash] ||
|
|
339
436
|
data_a[:dependencies_hash] != data_b[:dependencies_hash]
|
|
340
|
-
modified << { identifier: identifier, unit_type: data_b[:unit_type] }
|
|
437
|
+
modified << { identifier: data_b[:identifier], unit_type: data_b[:unit_type] }
|
|
341
438
|
end
|
|
342
439
|
else
|
|
343
|
-
added << { identifier: identifier, unit_type: data_b[:unit_type] }
|
|
440
|
+
added << { identifier: data_b[:identifier], unit_type: data_b[:unit_type] }
|
|
344
441
|
end
|
|
345
442
|
end
|
|
346
443
|
|
|
347
444
|
# Units in A but not B → deleted
|
|
348
445
|
units_a.each do |identifier, data_a|
|
|
349
|
-
deleted << { identifier: identifier, unit_type: data_a[:unit_type] } unless units_b.key?(identifier)
|
|
446
|
+
deleted << { identifier: data_a[:identifier], unit_type: data_a[:unit_type] } unless units_b.key?(identifier)
|
|
350
447
|
end
|
|
351
448
|
|
|
352
449
|
{ added: added, modified: modified, deleted: deleted }
|