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
|
@@ -14,6 +14,13 @@ module Woods
|
|
|
14
14
|
class ToolResponseRenderer
|
|
15
15
|
VALID_FORMATS = %i[claude markdown plain json].freeze
|
|
16
16
|
|
|
17
|
+
# Sections of graph_analysis.json in display order. Shared by the
|
|
18
|
+
# graph_analysis tool (enum and pagination) and the text renderers.
|
|
19
|
+
GRAPH_ANALYSIS_SECTIONS = %w[
|
|
20
|
+
orphans dead_ends hubs cycles bridges
|
|
21
|
+
cross_database_edges volatile_dependencies undeclared_package_edges
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
17
24
|
# Factory method to build the appropriate renderer for a format.
|
|
18
25
|
#
|
|
19
26
|
# @param format [Symbol] One of :claude, :markdown, :plain, :json
|
|
@@ -77,10 +77,28 @@ module Woods
|
|
|
77
77
|
|
|
78
78
|
private
|
|
79
79
|
|
|
80
|
+
# Abstract models are excluded. `ApplicationRecord` is a descendant of
|
|
81
|
+
# `ActiveRecord::Base` and its name appears in virtually every model file
|
|
82
|
+
# (`class User < ApplicationRecord`), so including it in the scan regex
|
|
83
|
+
# wired a `:code_reference` edge from nearly every model to it — graph
|
|
84
|
+
# noise that also inflated its PageRank into the top ranks, where it
|
|
85
|
+
# displaced real hubs in retrieval ranking.
|
|
86
|
+
#
|
|
87
|
+
# `abstract_class?` is guarded: a descendant that raises on it (a
|
|
88
|
+
# half-loaded class under the NameError fallback) is kept rather than
|
|
89
|
+
# dropped, since a phantom edge is a smaller error than a missing model.
|
|
80
90
|
def compute_model_names
|
|
81
91
|
return [] unless defined?(ActiveRecord::Base)
|
|
82
92
|
|
|
83
|
-
ActiveRecord::Base.descendants.filter_map(&:name).uniq
|
|
93
|
+
ActiveRecord::Base.descendants.reject { |klass| abstract_model?(klass) }.filter_map(&:name).uniq
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param klass [Class]
|
|
97
|
+
# @return [Boolean] true when the class is an abstract AR base class
|
|
98
|
+
def abstract_model?(klass)
|
|
99
|
+
klass.respond_to?(:abstract_class?) && klass.abstract_class?
|
|
100
|
+
rescue StandardError
|
|
101
|
+
false
|
|
84
102
|
end
|
|
85
103
|
|
|
86
104
|
def build_regex
|
data/lib/woods/notion/client.rb
CHANGED
|
@@ -9,6 +9,16 @@ require_relative 'rate_limiter'
|
|
|
9
9
|
|
|
10
10
|
module Woods
|
|
11
11
|
module Notion
|
|
12
|
+
# Raised on a 401/403 from the Notion API.
|
|
13
|
+
#
|
|
14
|
+
# A distinct class because authentication failure is not a per-unit
|
|
15
|
+
# problem: a bad or unshared token dooms every remaining call in the run.
|
|
16
|
+
# Without it the exporter's per-unit rescue recorded one error per unit and
|
|
17
|
+
# kept going, so a wrong token spent the entire cold sync at Notion's 3
|
|
18
|
+
# req/sec before reporting failure for everything. Subclasses
|
|
19
|
+
# {Woods::Error}, so existing `rescue Woods::Error` sites are unaffected.
|
|
20
|
+
class AuthenticationError < Woods::Error; end
|
|
21
|
+
|
|
12
22
|
# Thin wrapper around the Notion REST API (v2022-06-28).
|
|
13
23
|
#
|
|
14
24
|
# Uses Net::HTTP (stdlib) for zero external dependencies. All requests are
|
|
@@ -25,6 +35,31 @@ module Woods
|
|
|
25
35
|
MAX_RETRIES = 3
|
|
26
36
|
DEFAULT_TIMEOUT = 30
|
|
27
37
|
|
|
38
|
+
# Statuses that mean the credential itself is the problem, so no later
|
|
39
|
+
# request in this run can succeed either. Response codes arrive as
|
|
40
|
+
# strings from Net::HTTP.
|
|
41
|
+
AUTH_STATUS_CODES = %w[401 403].freeze
|
|
42
|
+
|
|
43
|
+
# HTTP methods safe to retry after *any* transient network failure —
|
|
44
|
+
# repeating an idempotent request cannot double-apply an operation.
|
|
45
|
+
# POST and PATCH are deliberately absent: see {#execute_with_retry}.
|
|
46
|
+
IDEMPOTENT_METHODS = %i[get put delete head].freeze
|
|
47
|
+
|
|
48
|
+
# Network failures that provably occur before the server could have
|
|
49
|
+
# processed the request (the connection was never established), so a
|
|
50
|
+
# retry is safe even for non-idempotent verbs.
|
|
51
|
+
PRE_REQUEST_ERRORS = [Net::OpenTimeout, Errno::ECONNREFUSED].freeze
|
|
52
|
+
|
|
53
|
+
# Response codes potentially retried. 429 is safe for every verb — it
|
|
54
|
+
# means the server throttled the request before processing it. 503 is
|
|
55
|
+
# NOT unconditionally safe: that status proves the *origin* refused to
|
|
56
|
+
# process the request, but it can also be synthesized by an
|
|
57
|
+
# intermediary sitting in front of an origin that already committed
|
|
58
|
+
# the write, so the response alone doesn't prove non-commitment.
|
|
59
|
+
# {#request}'s `idempotent:` flag gates the 503 retry accordingly —
|
|
60
|
+
# see {#execute_with_retry} for the equivalent network-level split.
|
|
61
|
+
RETRYABLE_STATUS_CODES = %w[429 503].freeze
|
|
62
|
+
|
|
28
63
|
# @param api_token [String] Notion integration API token
|
|
29
64
|
# @param rate_limiter [RateLimiter] Rate limiter instance (default: 3 req/sec)
|
|
30
65
|
# @raise [ArgumentError] if api_token is nil or empty
|
|
@@ -48,7 +83,10 @@ module Woods
|
|
|
48
83
|
}
|
|
49
84
|
body[:children] = children if children.any?
|
|
50
85
|
|
|
51
|
-
|
|
86
|
+
# Notion offers no idempotency key for page creation, and a 503 can
|
|
87
|
+
# be an intermediary's synthesized response for an origin that
|
|
88
|
+
# already committed — see {#request}.
|
|
89
|
+
request(:post, 'pages', body, idempotent: false)
|
|
52
90
|
end
|
|
53
91
|
|
|
54
92
|
# Update an existing page's properties.
|
|
@@ -71,32 +109,7 @@ module Woods
|
|
|
71
109
|
body[:filter] = filter if filter
|
|
72
110
|
body[:sorts] = sorts if sorts
|
|
73
111
|
|
|
74
|
-
request(:post, "databases/#{database_id}/query", body)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Query all pages from a database, auto-paginating.
|
|
78
|
-
#
|
|
79
|
-
# @param database_id [String] Database UUID
|
|
80
|
-
# @param filter [Hash, nil] Notion filter object
|
|
81
|
-
# @return [Array<Hash>] All matching pages
|
|
82
|
-
def query_all(database_id:, filter: nil)
|
|
83
|
-
all_results = []
|
|
84
|
-
cursor = nil
|
|
85
|
-
|
|
86
|
-
loop do
|
|
87
|
-
body = {}
|
|
88
|
-
body[:filter] = filter if filter
|
|
89
|
-
body[:start_cursor] = cursor if cursor
|
|
90
|
-
|
|
91
|
-
response = request(:post, "databases/#{database_id}/query", body)
|
|
92
|
-
all_results.concat(response['results'] || [])
|
|
93
|
-
|
|
94
|
-
break unless response['has_more']
|
|
95
|
-
|
|
96
|
-
cursor = response['next_cursor']
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
all_results
|
|
112
|
+
request(:post, "databases/#{database_id}/query", body, read_only: true)
|
|
100
113
|
end
|
|
101
114
|
|
|
102
115
|
# Find a page by its title property value.
|
|
@@ -124,17 +137,30 @@ module Woods
|
|
|
124
137
|
# @param method [Symbol] HTTP method (:post, :patch, :get)
|
|
125
138
|
# @param path [String] API path (appended to BASE_URL)
|
|
126
139
|
# @param body [Hash, nil] Request body
|
|
140
|
+
# @param idempotent [Boolean] Whether repeating this request cannot
|
|
141
|
+
# double-apply the operation. 429 always retries regardless; a 503
|
|
142
|
+
# only retries when `idempotent` is true, since a 503 can be an
|
|
143
|
+
# intermediary's response for an origin that already committed a
|
|
144
|
+
# non-idempotent write (see {RETRYABLE_STATUS_CODES}).
|
|
145
|
+
# @param read_only [Boolean] Whether this call reads without mutating
|
|
146
|
+
# Notion state, independent of HTTP verb — {#query_database} is a
|
|
147
|
+
# POST but commits nothing server-side, so a mid-exchange network
|
|
148
|
+
# failure is always safe to retry (see {#safe_to_retry?}).
|
|
127
149
|
# @return [Hash] Parsed JSON response
|
|
128
|
-
# @raise [Woods::Error] on non-success responses (after retries for 429
|
|
129
|
-
|
|
150
|
+
# @raise [Woods::Error] on non-success responses (after retries for 429,
|
|
151
|
+
# and for 503 when idempotent), or immediately for a non-idempotent
|
|
152
|
+
# request's 503
|
|
153
|
+
def request(method, path, body = nil, idempotent: true, read_only: false)
|
|
130
154
|
retries = 0
|
|
131
155
|
|
|
132
156
|
loop do
|
|
133
|
-
response = execute_with_retry(method, path, body)
|
|
157
|
+
response = execute_with_retry(method, path, body, read_only: read_only)
|
|
134
158
|
|
|
135
159
|
return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
|
|
136
160
|
|
|
137
|
-
if response.code == '
|
|
161
|
+
raise_ambiguous_response_error(method, response) if response.code == '503' && !idempotent
|
|
162
|
+
|
|
163
|
+
if RETRYABLE_STATUS_CODES.include?(response.code) && retries < MAX_RETRIES
|
|
138
164
|
retries += 1
|
|
139
165
|
# Retry-After may be an HTTP-date, which .to_f would collapse to
|
|
140
166
|
# 0.0 — honoring it as-is would hammer a throttling server.
|
|
@@ -147,24 +173,42 @@ module Woods
|
|
|
147
173
|
end
|
|
148
174
|
end
|
|
149
175
|
|
|
150
|
-
# Execute HTTP with rate limiting and network error retry.
|
|
176
|
+
# Execute HTTP with rate limiting and verb-aware network error retry.
|
|
177
|
+
#
|
|
178
|
+
# Retry classification depends on the verb. Idempotent verbs
|
|
179
|
+
# ({IDEMPOTENT_METHODS}) retry every transient failure. Non-idempotent
|
|
180
|
+
# verbs (POST, PATCH) retry only failures that provably happened before
|
|
181
|
+
# the server could have processed the request ({PRE_REQUEST_ERRORS});
|
|
182
|
+
# a mid-exchange failure (Net::ReadTimeout, ECONNRESET) raises instead,
|
|
183
|
+
# because the request may have been fully delivered and committed
|
|
184
|
+
# server-side before the connection died — retrying a phantom-committed
|
|
185
|
+
# POST creates a duplicate object. For pages the exporter's
|
|
186
|
+
# find-then-create upsert catches the duplicate on the *next* run and
|
|
187
|
+
# updates it in place, but a duplicated database has no such
|
|
188
|
+
# reconciliation, so a blind retry here is silent, permanent
|
|
189
|
+
# duplication in the workspace.
|
|
151
190
|
#
|
|
152
191
|
# Any message from an underlying network error is run through
|
|
153
192
|
# {#redact_token} before being re-raised — a malformed reflected
|
|
154
193
|
# URL or request dump from the stdlib must not leak the bearer
|
|
155
194
|
# token into logs or backtraces.
|
|
156
195
|
#
|
|
196
|
+
# @param method [Symbol] HTTP method, used to classify retryability
|
|
197
|
+
# @param read_only [Boolean] see {#request}
|
|
157
198
|
# @return [Net::HTTPResponse]
|
|
158
|
-
# @raise [Woods::Error] on persistent network failures
|
|
159
|
-
|
|
199
|
+
# @raise [Woods::Error] on persistent network failures, or immediately
|
|
200
|
+
# when a non-idempotent request fails ambiguously mid-exchange
|
|
201
|
+
def execute_with_retry(method, path, body, read_only: false)
|
|
160
202
|
attempts = 0
|
|
161
203
|
begin
|
|
162
204
|
@rate_limiter.throttle { execute_http(method, path, body) }
|
|
163
205
|
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED => e
|
|
206
|
+
raise_ambiguous_network_error(method, e) unless safe_to_retry?(method, e, read_only: read_only)
|
|
207
|
+
|
|
164
208
|
attempts += 1
|
|
165
209
|
if attempts >= MAX_RETRIES
|
|
166
210
|
raise Woods::Error,
|
|
167
|
-
"Network error after #{attempts}
|
|
211
|
+
"Network error after #{attempts} attempts: #{redact_token(e.message)}"
|
|
168
212
|
end
|
|
169
213
|
|
|
170
214
|
sleep(2**attempts)
|
|
@@ -172,6 +216,57 @@ module Woods
|
|
|
172
216
|
end
|
|
173
217
|
end
|
|
174
218
|
|
|
219
|
+
# Whether a failed request may be retried without risking a
|
|
220
|
+
# double-apply: the call is a read regardless of verb, the verb
|
|
221
|
+
# itself is idempotent, or the failure class proves the request
|
|
222
|
+
# never reached the server.
|
|
223
|
+
#
|
|
224
|
+
# @param method [Symbol] HTTP method
|
|
225
|
+
# @param error [Exception] the network failure
|
|
226
|
+
# @param read_only [Boolean] see {#request}
|
|
227
|
+
# @return [Boolean]
|
|
228
|
+
def safe_to_retry?(method, error, read_only: false)
|
|
229
|
+
read_only || IDEMPOTENT_METHODS.include?(method) || PRE_REQUEST_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Raise for a non-idempotent request that failed mid-exchange. The
|
|
233
|
+
# request may have been committed server-side before the failure, so
|
|
234
|
+
# the operator must verify workspace state before re-running rather
|
|
235
|
+
# than have the client silently duplicate the write.
|
|
236
|
+
#
|
|
237
|
+
# @param method [Symbol] HTTP method
|
|
238
|
+
# @param error [Exception] the ambiguous network failure
|
|
239
|
+
# @raise [Woods::Error] always
|
|
240
|
+
def raise_ambiguous_network_error(method, error)
|
|
241
|
+
raise Woods::Error,
|
|
242
|
+
"#{method.to_s.upcase} request interrupted mid-exchange (#{error.class}); " \
|
|
243
|
+
'the operation may or may not have been applied server-side. ' \
|
|
244
|
+
"Not retrying automatically to avoid duplicates; verify before re-running: #{redact_token(error.message)}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Raise for a non-idempotent request that received a 503. The response
|
|
248
|
+
# doesn't prove the origin never processed the request — an
|
|
249
|
+
# intermediary can synthesize a 503 for a request the origin already
|
|
250
|
+
# committed — so this mirrors {#raise_ambiguous_network_error}: same
|
|
251
|
+
# error class, same "verify before re-running" guidance, because the
|
|
252
|
+
# risk (a silently duplicated create) is identical.
|
|
253
|
+
#
|
|
254
|
+
# @param method [Symbol] HTTP method
|
|
255
|
+
# @param response [Net::HTTPResponse] the 503 response
|
|
256
|
+
# @raise [Woods::Error] always
|
|
257
|
+
def raise_ambiguous_response_error(method, response)
|
|
258
|
+
parsed = begin
|
|
259
|
+
JSON.parse(response.body)
|
|
260
|
+
rescue JSON::ParserError
|
|
261
|
+
{}
|
|
262
|
+
end
|
|
263
|
+
detail = parsed['message'] || 'Service Unavailable'
|
|
264
|
+
raise Woods::Error,
|
|
265
|
+
"#{method.to_s.upcase} request received 503 (#{redact_token(detail)}); " \
|
|
266
|
+
'the operation may or may not have been applied server-side. ' \
|
|
267
|
+
'Not retrying automatically to avoid duplicates; verify before re-running.'
|
|
268
|
+
end
|
|
269
|
+
|
|
175
270
|
# Raise a descriptive error from a non-success Notion response.
|
|
176
271
|
# The response body is scrubbed before being formatted into the
|
|
177
272
|
# exception — if the Notion API ever echoes back a header (or a
|
|
@@ -185,7 +280,8 @@ module Woods
|
|
|
185
280
|
{ 'message' => "Unparseable response body: #{response.body&.slice(0, 200)}" }
|
|
186
281
|
end
|
|
187
282
|
message = parsed['message'] || 'Unknown error'
|
|
188
|
-
|
|
283
|
+
error_class = AUTH_STATUS_CODES.include?(response.code) ? AuthenticationError : Woods::Error
|
|
284
|
+
raise error_class,
|
|
189
285
|
"Notion API error #{response.code}: #{redact_token(message)}"
|
|
190
286
|
end
|
|
191
287
|
|