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.
Files changed (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. 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
@@ -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
- request(:post, 'pages', body)
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
- def request(method, path, body = nil)
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 == '429' && retries < MAX_RETRIES
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
- def execute_with_retry(method, path, body)
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} retries: #{redact_token(e.message)}"
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
- raise Woods::Error,
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