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,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
3
5
|
module Woods
|
|
4
6
|
module SessionTracer
|
|
5
7
|
# Abstract store interface for session trace data.
|
|
@@ -60,7 +62,18 @@ module Woods
|
|
|
60
62
|
# @param session_id [String] Raw session identifier
|
|
61
63
|
# @return [String] Sanitized identifier (alphanumeric, hyphens, underscores only)
|
|
62
64
|
def sanitize_session_id(session_id)
|
|
63
|
-
session_id.to_s
|
|
65
|
+
raw = session_id.to_s
|
|
66
|
+
raise ArgumentError, 'session_id exceeds 180 bytes' if raw.bytesize > 180
|
|
67
|
+
|
|
68
|
+
"b64.#{Base64.urlsafe_encode64(raw, padding: false)}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def restore_session_id(encoded)
|
|
72
|
+
return encoded unless encoded.start_with?('b64.')
|
|
73
|
+
|
|
74
|
+
Base64.urlsafe_decode64(encoded.delete_prefix('b64.'))
|
|
75
|
+
rescue ArgumentError
|
|
76
|
+
encoded
|
|
64
77
|
end
|
|
65
78
|
|
|
66
79
|
# Build a session summary hash from a session ID and its requests.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
4
5
|
require 'time'
|
|
5
6
|
|
|
6
7
|
module Woods
|
|
@@ -18,11 +19,20 @@ module Woods
|
|
|
18
19
|
# its methods. The {SQLite} adapter is provided for local persistence.
|
|
19
20
|
#
|
|
20
21
|
# @example Using the SQLite adapter
|
|
21
|
-
# store = Woods::Storage::MetadataStore::SQLite.new(":memory:")
|
|
22
|
+
# store = Woods::Storage::MetadataStore::SQLite.new(database: ":memory:")
|
|
22
23
|
# store.store("User", { type: "model", file_path: "app/models/user.rb" })
|
|
23
24
|
# store.find("User")
|
|
24
25
|
#
|
|
25
26
|
module MetadataStore
|
|
27
|
+
# Conservative whitelist for caller-supplied search field names —
|
|
28
|
+
# mirrors the filter-key standard in {Woods::Storage::Pgvector#build_where}.
|
|
29
|
+
# The SQLite adapter interpolates field names into a
|
|
30
|
+
# `json_extract(data, '$.<field>')` JSON-path literal, so anything
|
|
31
|
+
# outside this charset must be rejected before any SQL is built: a
|
|
32
|
+
# crafted field name (quotes, parens, `--`) can otherwise break out of
|
|
33
|
+
# the string literal and alter the query shape.
|
|
34
|
+
SEARCH_FIELD_NAME = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
|
|
35
|
+
|
|
26
36
|
# Interface that all metadata store adapters must implement.
|
|
27
37
|
module Interface
|
|
28
38
|
# Store or update metadata for a unit.
|
|
@@ -68,6 +78,30 @@ module Woods
|
|
|
68
78
|
|
|
69
79
|
# Search metadata by text query across specified fields.
|
|
70
80
|
#
|
|
81
|
+
# **Contract every adapter must honour** (pinned by the
|
|
82
|
+
# `'hardened search'` shared examples in
|
|
83
|
+
# spec/storage/metadata_store_spec.rb, which run against every
|
|
84
|
+
# adapter — they were substitutable in name only until the semantics
|
|
85
|
+
# were written down):
|
|
86
|
+
#
|
|
87
|
+
# - **Substring match**, not word or prefix match.
|
|
88
|
+
# - **Case-insensitive.** InMemory used a case-sensitive
|
|
89
|
+
# `String#include?` while SQLite used `LIKE`, so the same query
|
|
90
|
+
# returned different results depending on the configured backend.
|
|
91
|
+
# Case-insensitive is both the search-like expectation and what the
|
|
92
|
+
# durable adapter already did. (SQLite's `LIKE` folds ASCII only, so
|
|
93
|
+
# non-ASCII case folding remains backend-specific — do not rely on
|
|
94
|
+
# it either way.)
|
|
95
|
+
# - **`fields: nil` searches the whole record**, including keys, as
|
|
96
|
+
# serialized JSON. A query can therefore match a field *name*.
|
|
97
|
+
# - **LIKE metacharacters are literal.** `%` and `_` in a query match
|
|
98
|
+
# themselves rather than acting as wildcards.
|
|
99
|
+
# - Field names are validated against {SEARCH_FIELD_NAME} by every
|
|
100
|
+
# adapter, so a hostile name raises the same ArgumentError
|
|
101
|
+
# everywhere.
|
|
102
|
+
# - **An empty `fields` list matches nothing.** Adapters return an
|
|
103
|
+
# empty result before querying their backing store.
|
|
104
|
+
#
|
|
71
105
|
# @param query [String] Text to search for
|
|
72
106
|
# @param fields [Array<String>, nil] Specific fields to search (nil = all)
|
|
73
107
|
# @return [Array<Hash>] Matching metadata records
|
|
@@ -76,6 +110,21 @@ module Woods
|
|
|
76
110
|
raise NotImplementedError
|
|
77
111
|
end
|
|
78
112
|
|
|
113
|
+
# Every stored identifier.
|
|
114
|
+
#
|
|
115
|
+
# Used by the evaluation harness's {Woods::Evaluation::BaselineRunner}
|
|
116
|
+
# to build naive baselines (grep/random/file-level) to score the real
|
|
117
|
+
# retrieval pipeline against. It called this on the configured store
|
|
118
|
+
# while no adapter implemented it, so every baseline strategy raised
|
|
119
|
+
# NoMethodError — invisible because the harness had no working entry
|
|
120
|
+
# point either (#212).
|
|
121
|
+
#
|
|
122
|
+
# @return [Array<String>] All identifiers, order unspecified
|
|
123
|
+
# @raise [NotImplementedError] if not implemented by adapter
|
|
124
|
+
def all_identifiers
|
|
125
|
+
raise NotImplementedError
|
|
126
|
+
end
|
|
127
|
+
|
|
79
128
|
# Delete a unit by ID.
|
|
80
129
|
#
|
|
81
130
|
# @param id [String] The identifier to delete
|
|
@@ -91,6 +140,30 @@ module Woods
|
|
|
91
140
|
def count
|
|
92
141
|
raise NotImplementedError
|
|
93
142
|
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
# Validate caller-supplied search field names against
|
|
147
|
+
# {SEARCH_FIELD_NAME}, stringifying as it goes (symbol fields were
|
|
148
|
+
# always accepted). Every adapter's {#search} must run its `fields:`
|
|
149
|
+
# through this BEFORE touching its backing store, so a hostile field
|
|
150
|
+
# name raises the same {ArgumentError} everywhere — including
|
|
151
|
+
# adapters (InMemory) where the name is not an injection vector,
|
|
152
|
+
# keeping the adapters substitutable.
|
|
153
|
+
#
|
|
154
|
+
# @param fields [Array<String, Symbol>, nil] Field names, or nil for "all fields"
|
|
155
|
+
# @return [Array<String>, nil] Stringified field names, or nil if fields was nil
|
|
156
|
+
# @raise [ArgumentError] if any field name fails the whitelist
|
|
157
|
+
def validate_search_fields!(fields)
|
|
158
|
+
return nil if fields.nil?
|
|
159
|
+
|
|
160
|
+
fields.map do |field|
|
|
161
|
+
field_s = field.to_s
|
|
162
|
+
raise ArgumentError, "Invalid search field: #{field_s.inspect}" unless field_s.match?(SEARCH_FIELD_NAME)
|
|
163
|
+
|
|
164
|
+
field_s
|
|
165
|
+
end
|
|
166
|
+
end
|
|
94
167
|
end
|
|
95
168
|
|
|
96
169
|
# Pure-Ruby metadata store backed by a hash. No external dependencies,
|
|
@@ -113,7 +186,7 @@ module Woods
|
|
|
113
186
|
|
|
114
187
|
# @see Interface#store
|
|
115
188
|
def store(id, metadata)
|
|
116
|
-
@data[id] =
|
|
189
|
+
@data[id] = normalize(metadata).merge('updated_at' => Time.now.iso8601)
|
|
117
190
|
end
|
|
118
191
|
|
|
119
192
|
# @see Interface#find
|
|
@@ -143,16 +216,43 @@ module Woods
|
|
|
143
216
|
end
|
|
144
217
|
|
|
145
218
|
# @see Interface#search
|
|
219
|
+
#
|
|
220
|
+
# Matching is literal substring inclusion — `%` and `_` in the query
|
|
221
|
+
# have no special meaning here, and the SQLite adapter escapes them
|
|
222
|
+
# so the two adapters agree.
|
|
223
|
+
#
|
|
224
|
+
# @raise [ArgumentError] if a field name fails {SEARCH_FIELD_NAME}
|
|
146
225
|
def search(query, fields: nil)
|
|
147
|
-
|
|
226
|
+
fields = validate_search_fields!(fields)
|
|
227
|
+
return [] if fields == []
|
|
228
|
+
|
|
229
|
+
# Case-insensitive, matching the SQLite adapter's `LIKE` (see the
|
|
230
|
+
# contract note on {Interface#search}). This used to be a
|
|
231
|
+
# case-sensitive `String#include?`, so the same query returned
|
|
232
|
+
# different results depending on which backend a host had configured.
|
|
233
|
+
needle = query.to_s.downcase
|
|
148
234
|
@data.each_with_object([]) do |(id, record), out|
|
|
149
|
-
|
|
150
|
-
|
|
235
|
+
# `updated_at` is excluded from the whole-record haystack: SQLite's
|
|
236
|
+
# `data` column never carries it (it's a store-level column there,
|
|
237
|
+
# not part of the JSON blob), so leaving it in here made a query
|
|
238
|
+
# that only matched a timestamp (e.g. "2026-08") return every
|
|
239
|
+
# record on InMemory and none on SQLite.
|
|
240
|
+
haystacks = if fields
|
|
241
|
+
fields.map { |f| field_haystack(record[f]) }
|
|
242
|
+
else
|
|
243
|
+
[JSON.generate(record.except('updated_at'))]
|
|
244
|
+
end
|
|
245
|
+
next unless haystacks.compact.any? { |h| h.downcase.include?(needle) }
|
|
151
246
|
|
|
152
247
|
out << record.except('updated_at').merge('id' => id)
|
|
153
248
|
end
|
|
154
249
|
end
|
|
155
250
|
|
|
251
|
+
# @see Interface#all_identifiers
|
|
252
|
+
def all_identifiers
|
|
253
|
+
@data.keys
|
|
254
|
+
end
|
|
255
|
+
|
|
156
256
|
# @see Interface#delete
|
|
157
257
|
def delete(id)
|
|
158
258
|
@data.delete(id)
|
|
@@ -196,13 +296,37 @@ module Woods
|
|
|
196
296
|
|
|
197
297
|
private
|
|
198
298
|
|
|
199
|
-
# Match the SQLite adapter's
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
|
|
205
|
-
|
|
299
|
+
# Match the SQLite adapter's contract regardless of how the caller
|
|
300
|
+
# serialises the input hash.
|
|
301
|
+
#
|
|
302
|
+
# The SQLite path stores +JSON.generate(metadata)+ and reads it back
|
|
303
|
+
# with +JSON.parse+, so every key *and value* comes back as JSON
|
|
304
|
+
# types: symbols become strings, nested hashes are stringified all the
|
|
305
|
+
# way down. Stringifying only the top-level keys left the two adapters
|
|
306
|
+
# returning different values for the same input (STO-8), so the round
|
|
307
|
+
# trip is performed here too rather than approximated.
|
|
308
|
+
#
|
|
309
|
+
# @param metadata [Hash]
|
|
310
|
+
# @return [Hash] string-keyed, JSON-typed values
|
|
311
|
+
def normalize(metadata)
|
|
312
|
+
JSON.parse(JSON.generate(metadata))
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The text SQLite's +json_extract(data, '$.field')+ would compare
|
|
316
|
+
# against for one field value: strings come back raw, structured
|
|
317
|
+
# values as their JSON text, scalars as their decimal form. A Ruby
|
|
318
|
+
# +Hash#to_s+ haystack used to leak `=>` and `:sym` syntax that no
|
|
319
|
+
# JSON document contains (STO-8).
|
|
320
|
+
#
|
|
321
|
+
# @param value [Object] the stored field value
|
|
322
|
+
# @return [String, nil] nil for a missing field, which never matches
|
|
323
|
+
def field_haystack(value)
|
|
324
|
+
case value
|
|
325
|
+
when nil then nil
|
|
326
|
+
when String then value
|
|
327
|
+
when Hash, Array then JSON.generate(value)
|
|
328
|
+
else value.to_s
|
|
329
|
+
end
|
|
206
330
|
end
|
|
207
331
|
end
|
|
208
332
|
|
|
@@ -212,15 +336,19 @@ module Woods
|
|
|
212
336
|
# for efficient filtering. Uses upsert semantics for store operations.
|
|
213
337
|
#
|
|
214
338
|
# @example
|
|
215
|
-
# store = SQLite.new(":memory:")
|
|
339
|
+
# store = SQLite.new(database: ":memory:")
|
|
216
340
|
# store.store("User", { type: "model", namespace: "Admin" })
|
|
217
341
|
# store.find("User") # => { "type" => "model", "namespace" => "Admin" }
|
|
218
342
|
#
|
|
219
|
-
class SQLite
|
|
343
|
+
class SQLite # rubocop:disable Metrics/ClassLength
|
|
220
344
|
include Interface
|
|
221
345
|
|
|
222
|
-
#
|
|
223
|
-
|
|
346
|
+
# Attempts allowed for a contended write, including the first.
|
|
347
|
+
# Mirrors {Woods::Temporal::SnapshotStore::LOCK_ATTEMPTS}.
|
|
348
|
+
LOCK_ATTEMPTS = 3
|
|
349
|
+
|
|
350
|
+
# @param database [String, Pathname] Database file, or ":memory:" for an explicit in-memory store
|
|
351
|
+
def initialize(database:)
|
|
224
352
|
begin
|
|
225
353
|
require 'sqlite3'
|
|
226
354
|
rescue LoadError
|
|
@@ -229,21 +357,42 @@ module Woods
|
|
|
229
357
|
"Add `gem 'sqlite3'` and re-bundle, or set " \
|
|
230
358
|
"`config.metadata_store = :in_memory` if you don't need cross-process persistence."
|
|
231
359
|
end
|
|
232
|
-
|
|
360
|
+
database = database.to_s
|
|
361
|
+
FileUtils.mkdir_p(File.dirname(File.expand_path(database))) unless database == ':memory:'
|
|
362
|
+
@db = ::SQLite3::Database.new(database)
|
|
233
363
|
@db.results_as_hash = true
|
|
364
|
+
# SQLite's busy handler only helps when it is set. Two processes
|
|
365
|
+
# sharing one metadata database (a long extraction while an MCP
|
|
366
|
+
# server stores a checkpoint) used to raise SQLite3::BusyException
|
|
367
|
+
# immediately on the second writer (O2). Same value as the twin
|
|
368
|
+
# {Woods::Temporal::SnapshotStore}.
|
|
369
|
+
@db.busy_timeout = 5_000
|
|
234
370
|
create_table
|
|
235
371
|
end
|
|
236
372
|
|
|
237
373
|
# @see Interface#store
|
|
374
|
+
# @raise [ArgumentError] if +metadata+ carries no type key
|
|
375
|
+
#
|
|
376
|
+
# The type column backs {#find_by_type}, so an absent key used to
|
|
377
|
+
# fall through +type.to_s+ and be stored as +""+ — fabricating a
|
|
378
|
+
# type where none existed rather than surfacing the missing field
|
|
379
|
+
# (L22). A present-but-blank type fabricates exactly the same empty
|
|
380
|
+
# column, so it is refused on the same grounds (STO-9).
|
|
238
381
|
def store(id, metadata)
|
|
239
382
|
type = metadata[:type] || metadata['type']
|
|
383
|
+
if type.nil? || type.to_s.strip.empty?
|
|
384
|
+
raise ArgumentError, "metadata for #{id.inspect} has no usable type key (got #{type.inspect})"
|
|
385
|
+
end
|
|
386
|
+
|
|
240
387
|
data = JSON.generate(metadata)
|
|
241
388
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
389
|
+
with_lock_retry do
|
|
390
|
+
@db.execute(<<~SQL, [id, type.to_s, data, Time.now.iso8601])
|
|
391
|
+
INSERT INTO units (id, type, data, updated_at) VALUES (?, ?, ?, ?)
|
|
392
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
393
|
+
type = excluded.type, data = excluded.data, updated_at = excluded.updated_at
|
|
394
|
+
SQL
|
|
395
|
+
end
|
|
247
396
|
end
|
|
248
397
|
|
|
249
398
|
# @see Interface#find
|
|
@@ -272,21 +421,42 @@ module Woods
|
|
|
272
421
|
end
|
|
273
422
|
|
|
274
423
|
# @see Interface#search
|
|
424
|
+
#
|
|
425
|
+
# Field names are interpolated into a `json_extract` JSON-path
|
|
426
|
+
# literal, so they are validated against {SEARCH_FIELD_NAME} first —
|
|
427
|
+
# a crafted name could otherwise break out of the literal and alter
|
|
428
|
+
# the SQL shape. LIKE metacharacters (`%`, `_`, `\`) in the query are
|
|
429
|
+
# escaped (with an explicit ESCAPE clause) so they match literally,
|
|
430
|
+
# aligning with the InMemory adapter's substring semantics instead of
|
|
431
|
+
# silently broadening matches.
|
|
432
|
+
#
|
|
433
|
+
# @raise [ArgumentError] if a field name fails the whitelist
|
|
275
434
|
def search(query, fields: nil)
|
|
435
|
+
fields = validate_search_fields!(fields)
|
|
436
|
+
return [] if fields == []
|
|
437
|
+
|
|
438
|
+
pattern = "%#{escape_like(query)}%"
|
|
276
439
|
if fields
|
|
277
|
-
conditions = fields.map { "json_extract(data, '$.#{_1}') LIKE ?" }.join(' OR ')
|
|
278
|
-
params = fields.
|
|
440
|
+
conditions = fields.map { "json_extract(data, '$.#{_1}') LIKE ? ESCAPE '\\'" }.join(' OR ')
|
|
441
|
+
params = Array.new(fields.size, pattern)
|
|
279
442
|
rows = @db.execute("SELECT id, data FROM units WHERE #{conditions}", params)
|
|
280
443
|
else
|
|
281
|
-
rows = @db.execute(
|
|
444
|
+
rows = @db.execute("SELECT id, data FROM units WHERE data LIKE ? ESCAPE '\\'", [pattern])
|
|
282
445
|
end
|
|
283
446
|
|
|
284
447
|
rows.map { |row| parse_row(row) }
|
|
285
448
|
end
|
|
286
449
|
|
|
450
|
+
# @see Interface#all_identifiers
|
|
451
|
+
def all_identifiers
|
|
452
|
+
@db.execute('SELECT id FROM units').map { |row| row['id'] }
|
|
453
|
+
end
|
|
454
|
+
|
|
287
455
|
# @see Interface#delete
|
|
288
456
|
def delete(id)
|
|
289
|
-
|
|
457
|
+
with_lock_retry do
|
|
458
|
+
@db.execute('DELETE FROM units WHERE id = ?', [id])
|
|
459
|
+
end
|
|
290
460
|
end
|
|
291
461
|
|
|
292
462
|
# @see Interface#count
|
|
@@ -296,6 +466,40 @@ module Woods
|
|
|
296
466
|
|
|
297
467
|
private
|
|
298
468
|
|
|
469
|
+
# Bounded retry around a contended write, mirroring
|
|
470
|
+
# {Woods::Temporal::SnapshotStore#with_lock_retry}: SQLite skips the
|
|
471
|
+
# busy handler when it detects a lock-order deadlock, so a write can
|
|
472
|
+
# raise "database is locked" at once despite the busy timeout. A
|
|
473
|
+
# short bounded retry lets the other writer finish; anything else,
|
|
474
|
+
# or a non-SQLite connection, raises straight through.
|
|
475
|
+
def with_lock_retry
|
|
476
|
+
attempt = 0
|
|
477
|
+
begin
|
|
478
|
+
attempt += 1
|
|
479
|
+
yield
|
|
480
|
+
rescue StandardError => e
|
|
481
|
+
raise unless sqlite_busy?(e) && attempt < LOCK_ATTEMPTS
|
|
482
|
+
|
|
483
|
+
sleep(0.05 * attempt)
|
|
484
|
+
retry
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def sqlite_busy?(error)
|
|
489
|
+
defined?(SQLite3::BusyException) && error.is_a?(SQLite3::BusyException)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Escape SQL LIKE metacharacters in a user query so they match
|
|
493
|
+
# literally under the `ESCAPE '\'` clause {#search} emits. Without
|
|
494
|
+
# this, `%` and `_` in a query act as wildcards and silently broaden
|
|
495
|
+
# matches (`"user_name"` would match `"userXname"`).
|
|
496
|
+
#
|
|
497
|
+
# @param query [String] Raw search query
|
|
498
|
+
# @return [String] Query safe for embedding in a LIKE pattern
|
|
499
|
+
def escape_like(query)
|
|
500
|
+
query.to_s.gsub(/[\\%_]/) { |ch| "\\#{ch}" }
|
|
501
|
+
end
|
|
502
|
+
|
|
299
503
|
# Parse a database row into a metadata hash with the id field injected.
|
|
300
504
|
#
|
|
301
505
|
# @param row [Hash] Database row with 'id' and 'data' keys
|