woods 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +169 -0
  3. data/README.md +20 -8
  4. data/exe/woods-console +51 -6
  5. data/exe/woods-console-mcp +24 -4
  6. data/exe/woods-mcp +30 -7
  7. data/exe/woods-mcp-http +47 -6
  8. data/lib/generators/woods/install_generator.rb +13 -4
  9. data/lib/generators/woods/templates/woods.rb.tt +155 -0
  10. data/lib/tasks/woods.rake +15 -50
  11. data/lib/woods/builder.rb +174 -9
  12. data/lib/woods/cache/cache_middleware.rb +360 -31
  13. data/lib/woods/chunking/semantic_chunker.rb +334 -7
  14. data/lib/woods/console/adapters/job_adapter.rb +10 -4
  15. data/lib/woods/console/audit_logger.rb +76 -4
  16. data/lib/woods/console/bridge.rb +48 -15
  17. data/lib/woods/console/bridge_protocol.rb +44 -0
  18. data/lib/woods/console/confirmation.rb +3 -4
  19. data/lib/woods/console/console_response_renderer.rb +56 -18
  20. data/lib/woods/console/credential_index.rb +201 -0
  21. data/lib/woods/console/credential_scanner.rb +302 -0
  22. data/lib/woods/console/dispatch_pipeline.rb +138 -0
  23. data/lib/woods/console/embedded_executor.rb +682 -35
  24. data/lib/woods/console/eval_guard.rb +319 -0
  25. data/lib/woods/console/model_validator.rb +1 -3
  26. data/lib/woods/console/rack_middleware.rb +185 -29
  27. data/lib/woods/console/redactor.rb +161 -0
  28. data/lib/woods/console/response_context.rb +127 -0
  29. data/lib/woods/console/safe_context.rb +220 -23
  30. data/lib/woods/console/scope_predicate_parser.rb +131 -0
  31. data/lib/woods/console/server.rb +417 -486
  32. data/lib/woods/console/sql_noise_stripper.rb +87 -0
  33. data/lib/woods/console/sql_table_scanner.rb +213 -0
  34. data/lib/woods/console/sql_validator.rb +81 -31
  35. data/lib/woods/console/table_gate.rb +93 -0
  36. data/lib/woods/console/tool_specs.rb +552 -0
  37. data/lib/woods/console/tools/tier1.rb +3 -3
  38. data/lib/woods/console/tools/tier4.rb +7 -1
  39. data/lib/woods/dependency_graph.rb +66 -7
  40. data/lib/woods/embedding/indexer.rb +190 -6
  41. data/lib/woods/embedding/openai.rb +40 -4
  42. data/lib/woods/embedding/provider.rb +104 -8
  43. data/lib/woods/embedding/text_preparer.rb +23 -3
  44. data/lib/woods/embedding/token_counter.rb +133 -0
  45. data/lib/woods/evaluation/baseline_runner.rb +20 -2
  46. data/lib/woods/evaluation/metrics.rb +4 -1
  47. data/lib/woods/extracted_unit.rb +1 -0
  48. data/lib/woods/extractor.rb +7 -1
  49. data/lib/woods/extractors/controller_extractor.rb +6 -0
  50. data/lib/woods/extractors/mailer_extractor.rb +16 -2
  51. data/lib/woods/extractors/model_extractor.rb +6 -1
  52. data/lib/woods/extractors/phlex_extractor.rb +13 -4
  53. data/lib/woods/extractors/rails_source_extractor.rb +2 -0
  54. data/lib/woods/extractors/route_helper_resolver.rb +130 -0
  55. data/lib/woods/extractors/shared_dependency_scanner.rb +130 -2
  56. data/lib/woods/extractors/view_component_extractor.rb +12 -1
  57. data/lib/woods/extractors/view_engines/base.rb +141 -0
  58. data/lib/woods/extractors/view_engines/erb.rb +145 -0
  59. data/lib/woods/extractors/view_template_extractor.rb +92 -133
  60. data/lib/woods/flow_assembler.rb +23 -15
  61. data/lib/woods/flow_precomputer.rb +21 -2
  62. data/lib/woods/graph_analyzer.rb +3 -4
  63. data/lib/woods/index_artifact.rb +173 -0
  64. data/lib/woods/mcp/bearer_auth.rb +45 -0
  65. data/lib/woods/mcp/bootstrap_state.rb +94 -0
  66. data/lib/woods/mcp/bootstrapper.rb +337 -16
  67. data/lib/woods/mcp/config_resolver.rb +288 -0
  68. data/lib/woods/mcp/errors.rb +134 -0
  69. data/lib/woods/mcp/index_reader.rb +265 -30
  70. data/lib/woods/mcp/origin_guard.rb +132 -0
  71. data/lib/woods/mcp/provider_probe.rb +166 -0
  72. data/lib/woods/mcp/renderers/claude_renderer.rb +6 -0
  73. data/lib/woods/mcp/renderers/markdown_renderer.rb +39 -3
  74. data/lib/woods/mcp/renderers/plain_renderer.rb +16 -2
  75. data/lib/woods/mcp/server.rb +737 -137
  76. data/lib/woods/model_name_cache.rb +78 -2
  77. data/lib/woods/notion/client.rb +25 -2
  78. data/lib/woods/notion/mappers/model_mapper.rb +36 -2
  79. data/lib/woods/railtie.rb +55 -15
  80. data/lib/woods/resilience/circuit_breaker.rb +9 -2
  81. data/lib/woods/resilience/retryable_provider.rb +40 -3
  82. data/lib/woods/resolved_config.rb +299 -0
  83. data/lib/woods/retrieval/context_assembler.rb +112 -5
  84. data/lib/woods/retrieval/query_classifier.rb +1 -1
  85. data/lib/woods/retrieval/ranker.rb +55 -6
  86. data/lib/woods/retrieval/search_executor.rb +42 -13
  87. data/lib/woods/retriever.rb +330 -24
  88. data/lib/woods/session_tracer/middleware.rb +35 -1
  89. data/lib/woods/storage/graph_store.rb +39 -0
  90. data/lib/woods/storage/inapplicable_backend.rb +14 -0
  91. data/lib/woods/storage/metadata_store.rb +129 -1
  92. data/lib/woods/storage/pgvector.rb +70 -8
  93. data/lib/woods/storage/qdrant.rb +196 -5
  94. data/lib/woods/storage/snapshotter/metadata.rb +172 -0
  95. data/lib/woods/storage/snapshotter/vector.rb +238 -0
  96. data/lib/woods/storage/snapshotter.rb +24 -0
  97. data/lib/woods/storage/vector_store.rb +184 -35
  98. data/lib/woods/tasks.rb +85 -0
  99. data/lib/woods/temporal/snapshot_store.rb +49 -1
  100. data/lib/woods/token_utils.rb +44 -5
  101. data/lib/woods/unblocked/client.rb +1 -1
  102. data/lib/woods/unblocked/document_builder.rb +35 -10
  103. data/lib/woods/unblocked/exporter.rb +1 -1
  104. data/lib/woods/util/host_guard.rb +61 -0
  105. data/lib/woods/version.rb +1 -1
  106. data/lib/woods.rb +126 -6
  107. metadata +69 -4
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative '../resolved_config'
5
+
6
+ module Woods
7
+ module MCP
8
+ # Resolves the embedding configuration that the MCP server should use.
9
+ #
10
+ # Reads +woods.json+ (the resolved config snapshot written at embed time),
11
+ # applies environment-variable overrides, validates, and returns either a
12
+ # populated {Woods::Configuration} or raises a typed {BootstrapError}
13
+ # subclass that the caller can present to an operator.
14
+ #
15
+ # This class is extracted from {Bootstrapper} to isolate the
16
+ # config-resolution concern from network probing and store construction.
17
+ # {Bootstrapper} delegates to {.resolve} as its first step.
18
+ #
19
+ # Resolution order (highest priority first):
20
+ #
21
+ # 1. Host Rails initializer (Woods.configuration already has embedding_provider).
22
+ # When +woods.json+ is also present the stored config is loaded and asserted
23
+ # compatible via {ResolvedConfig#assert_compatible!}.
24
+ # 2. +woods.json+ snapshot alone (MCP server running without a host initializer).
25
+ # The stored config is used to populate +config+ in place.
26
+ # 3. Environment-variable auto-detect (deprecated, opt-in via
27
+ # +WOODS_ALLOW_AUTODETECT=1+). Mutates +config+ to set provider and stores.
28
+ # 4. If none of the above applies, raises {MissingArtifact}.
29
+ #
30
+ # @example Typical Bootstrapper usage
31
+ # config, source = ConfigResolver.resolve(Woods.configuration, artifact: artifact)
32
+ # # config.embedding_provider is now guaranteed to be non-nil (or :none was returned)
33
+ #
34
+ module ConfigResolver
35
+ # Resolve and validate the embedding configuration.
36
+ #
37
+ # When +woods.json+ is present in the artifact directory it is parsed into
38
+ # a {ResolvedConfig} and, if the host already has a provider configured,
39
+ # validated for compatibility (dimension + provider class/model must agree).
40
+ # If the host has no provider configured the stored config is applied to
41
+ # +config+ so {Builder} can construct the correct provider.
42
+ #
43
+ # When +woods.json+ is absent and the host already has a provider
44
+ # configured, the host config is trusted and used as-is.
45
+ #
46
+ # When +woods.json+ is absent and the host has no provider configured:
47
+ # - If +WOODS_ALLOW_AUTODETECT+ is +1+, falls through to env-var
48
+ # auto-detect (deprecated, emits a structured warning).
49
+ # - Otherwise raises {MissingArtifact}.
50
+ #
51
+ # @param config [Woods::Configuration] the live host configuration object.
52
+ # May be mutated in the auto-detect path.
53
+ # @param artifact [Woods::IndexArtifact, nil] the on-disk artifact wrapper.
54
+ # @param env [Hash] environment variable source (default +ENV+, overridable
55
+ # in specs without stubbing the global ENV).
56
+ # @param ollama_probe [#call, nil] callable used to check Ollama reachability
57
+ # in the deprecated auto-detect path. Defaults to {.ollama_reachable?} on
58
+ # this module. Callers may pass a different probe to facilitate testing
59
+ # without touching global state.
60
+ # @return [Array(Woods::Configuration, Symbol)] tuple of +[config, source]+
61
+ # where +source+ is one of +:snapshot+, +:host_config+, +:autodetect+,
62
+ # or +:none+. The config is the same object passed in, possibly mutated.
63
+ # @raise [Woods::MCP::MissingArtifact] when +woods.json+ is absent, the
64
+ # host has no provider configured, and +WOODS_ALLOW_AUTODETECT+ is unset.
65
+ # @raise [Woods::MCP::UnsupportedArtifact] when +woods.json+ has an
66
+ # unsupported +schema_version+.
67
+ # @raise [Woods::MCP::DimensionMismatch] when stored and live provider
68
+ # dimensions disagree.
69
+ # @raise [Woods::MCP::ConfigMismatch] when stored and live provider
70
+ # class/model disagree.
71
+ def self.resolve(config, artifact:, env: ENV, ollama_probe: nil)
72
+ stored = read_stored_config(artifact)
73
+
74
+ if stored
75
+ [apply_stored_config(config, stored, artifact: artifact, env: env), :snapshot]
76
+ elsif config.embedding_provider
77
+ # Host initializer configured a provider; no woods.json to validate
78
+ # against. Trust the host config and proceed.
79
+ [config, :host_config]
80
+ else
81
+ resolve_without_artifact(config, artifact: artifact, env: env, ollama_probe: ollama_probe)
82
+ end
83
+ end
84
+
85
+ # Read and parse +woods.json+ if it exists.
86
+ #
87
+ # @param artifact [Woods::IndexArtifact, nil]
88
+ # @return [Woods::ResolvedConfig, nil]
89
+ # @raise [Woods::MCP::UnsupportedArtifact] if the file has an unsupported
90
+ # schema version.
91
+ def self.read_stored_config(artifact)
92
+ return nil unless artifact&.config_path&.exist?
93
+
94
+ raw = artifact.read_config
95
+ return nil unless raw
96
+
97
+ ResolvedConfig.from_hash(raw)
98
+ end
99
+ private_class_method :read_stored_config
100
+
101
+ # Reconcile the live host config against a stored {ResolvedConfig}.
102
+ #
103
+ # When the host has an +embedding_provider+ configured, assert that it
104
+ # matches the stored config. When the host has no provider configured,
105
+ # populate +config+ from the stored values so {Builder} can construct
106
+ # the correct provider.
107
+ #
108
+ # @param config [Woods::Configuration]
109
+ # @param stored [Woods::ResolvedConfig]
110
+ # @param artifact [Woods::IndexArtifact]
111
+ # @return [Woods::Configuration]
112
+ def self.apply_stored_config(config, stored, artifact:, env: ENV)
113
+ if config.embedding_provider
114
+ live = live_resolved_config(config)
115
+ live.assert_compatible!(stored)
116
+ config
117
+ else
118
+ populate_from_stored(config, stored, artifact: artifact, env: env)
119
+ end
120
+ end
121
+ private_class_method :apply_stored_config
122
+
123
+ # Build a ResolvedConfig from the live host config, probing the
124
+ # provider's dimension when possible. Tolerant of probe failures
125
+ # (unreachable Ollama): falls back to the declared-only path so
126
+ # {ResolvedConfig#assert_compatible!} surfaces a typed
127
+ # {DimensionMismatch} instead of a raw +Errno::ECONNREFUSED+
128
+ # escaping the caller's {BootstrapError} rescue.
129
+ def self.live_resolved_config(config)
130
+ require_relative '../builder'
131
+ provider = Woods::Builder.new(config).build_embedding_provider
132
+ ResolvedConfig.from_configuration(config, provider: provider)
133
+ rescue StandardError
134
+ ResolvedConfig.from_configuration(config)
135
+ end
136
+ private_class_method :live_resolved_config
137
+
138
+ # Populate a blank {Woods::Configuration} from a stored {ResolvedConfig}.
139
+ #
140
+ # Maps the serialised provider class name back to the +:ollama+ /
141
+ # +:openai+ symbol that {Builder} expects, and restores store types.
142
+ # Applied when an MCP server starts without a host Rails initializer.
143
+ #
144
+ # @param config [Woods::Configuration]
145
+ # @param stored [Woods::ResolvedConfig]
146
+ # @param artifact [Woods::IndexArtifact]
147
+ # @return [Woods::Configuration]
148
+ def self.populate_from_stored(config, stored, artifact:, env: ENV)
149
+ config.vector_store = stored.stores[:vector_store] || :in_memory
150
+ config.metadata_store = stored.stores[:metadata_store] || :in_memory
151
+ config.graph_store = stored.stores[:graph_store] || :in_memory
152
+ provider_sym = provider_symbol(stored.embedding_provider[:class])
153
+ config.embedding_provider = provider_sym
154
+
155
+ opts = {}
156
+ opts[:model] = stored.embedding_provider[:model] if stored.embedding_provider[:model]
157
+ opts[:host] = stored.embedding_provider[:host] if stored.embedding_provider[:host]
158
+ opts[:num_ctx] = stored.embedding_provider[:num_ctx] if stored.embedding_provider[:num_ctx]
159
+ opts[:read_timeout] = stored.embedding_provider[:read_timeout] if stored.embedding_provider[:read_timeout]
160
+ opts[:dimension] = stored.embedding_provider[:dimension] if stored.embedding_provider[:dimension]&.positive?
161
+
162
+ # OpenAI needs an api_key to construct. `woods.json` deliberately
163
+ # never stores credentials; pull from env here so the standalone
164
+ # MCP server can boot against an OpenAI-embedded index. Raise a
165
+ # typed error if the env var is missing — previously the generic
166
+ # ArgumentError from OpenAI.new(**opts) wasn't a BootstrapError
167
+ # and the top-level rescue in exe/woods-mcp wouldn't catch it.
168
+ if provider_sym == :openai
169
+ api_key = env.fetch('OPENAI_API_KEY', nil)
170
+ if api_key.nil? || api_key.empty?
171
+ raise Woods::MCP::MissingCredential.new(
172
+ 'woods.json says the index was embedded with OpenAI but OPENAI_API_KEY is unset. ' \
173
+ 'Export the key before starting the MCP server, or re-embed with a different provider.',
174
+ details: { provider: 'openai', missing_env_var: 'OPENAI_API_KEY' }
175
+ )
176
+ end
177
+
178
+ opts[:api_key] = api_key
179
+ end
180
+
181
+ config.embedding_options = opts unless opts.empty?
182
+
183
+ warn "[woods-mcp] config_source: loaded from woods.json (#{artifact.config_path})"
184
+ config
185
+ end
186
+ private_class_method :populate_from_stored
187
+
188
+ # Convert a fully-qualified provider class name back to the symbol
189
+ # that {Builder} understands.
190
+ #
191
+ # @param class_name [String]
192
+ # @return [Symbol, String] symbol for known providers, raw string otherwise
193
+ def self.provider_symbol(class_name)
194
+ case class_name.to_s
195
+ when /Ollama/ then :ollama
196
+ when /OpenAI/ then :openai
197
+ else class_name.to_s
198
+ end
199
+ end
200
+ private_class_method :provider_symbol
201
+
202
+ # Handle the no-artifact, no-host-provider case.
203
+ #
204
+ # Raises {MissingArtifact} unless +WOODS_ALLOW_AUTODETECT=1+ opts in to
205
+ # the deprecated env-var auto-detect path.
206
+ #
207
+ # @param config [Woods::Configuration]
208
+ # @param artifact [Woods::IndexArtifact, nil]
209
+ # @param env [Hash]
210
+ # @param ollama_probe [#call, nil]
211
+ # @return [Woods::Configuration]
212
+ # @raise [Woods::MCP::MissingArtifact]
213
+ def self.resolve_without_artifact(config, artifact:, env:, ollama_probe:)
214
+ if env['WOODS_ALLOW_AUTODETECT'] != '1'
215
+ raise MissingArtifact.new(
216
+ 'No woods.json found and WOODS_ALLOW_AUTODETECT is unset. ' \
217
+ 'Run `bundle exec rake woods:extract` in your host app, or set ' \
218
+ 'WOODS_ALLOW_AUTODETECT=1 to probe env vars (deprecated).',
219
+ details: { output_dir: artifact&.output_dir&.to_s }
220
+ )
221
+ end
222
+
223
+ warn '[woods-mcp] deprecated_autodetect: falling back to env-var auto-detect (no woods.json found)'
224
+ [autodetect_from_env(config, env: env, ollama_probe: ollama_probe), :autodetect]
225
+ end
226
+ private_class_method :resolve_without_artifact
227
+
228
+ # Probe environment variables for provider credentials and configure
229
+ # +config+ accordingly.
230
+ #
231
+ # Only reachable when +WOODS_ALLOW_AUTODETECT=1+ and no +woods.json+
232
+ # is present. Mutates +config+ to set provider and stores when a
233
+ # credential or reachable Ollama instance is found.
234
+ #
235
+ # @param config [Woods::Configuration]
236
+ # @param env [Hash]
237
+ # @param ollama_probe [#call, nil] callable for Ollama reachability check;
238
+ # defaults to {.ollama_reachable?} on this module when nil.
239
+ # @return [Woods::Configuration]
240
+ def self.autodetect_from_env(config, env:, ollama_probe:)
241
+ probe = ollama_probe || method(:ollama_reachable?)
242
+ openai_key = env.fetch('OPENAI_API_KEY', nil)
243
+ if openai_key
244
+ config.vector_store = :in_memory
245
+ config.metadata_store = :in_memory
246
+ config.graph_store = :in_memory
247
+ config.embedding_provider = :openai
248
+ config.embedding_options = { api_key: openai_key }
249
+ elsif probe.call
250
+ config.vector_store = :in_memory
251
+ config.metadata_store = :in_memory
252
+ config.graph_store = :in_memory
253
+ config.embedding_provider = :ollama
254
+ config.embedding_options = {
255
+ host: env.fetch('OLLAMA_BASE_URL', 'http://localhost:11434'),
256
+ model: env.fetch('OLLAMA_EMBED_MODEL', 'nomic-embed-text')
257
+ }
258
+ end
259
+
260
+ config
261
+ end
262
+ private_class_method :autodetect_from_env
263
+
264
+ # Check whether Ollama is reachable at the configured base URL.
265
+ #
266
+ # Used as the default probe in the deprecated auto-detect path. Callers
267
+ # may inject a different probe via +ollama_probe:+ kwarg.
268
+ #
269
+ # @return [Boolean]
270
+ def self.ollama_reachable?
271
+ require 'net/http'
272
+ require 'uri'
273
+
274
+ base_url = ENV.fetch('OLLAMA_BASE_URL', 'http://localhost:11434')
275
+ uri = URI.parse(base_url)
276
+ Net::HTTP.start(uri.host, uri.port,
277
+ open_timeout: 0.5, read_timeout: 0.5,
278
+ use_ssl: uri.scheme == 'https') do |http|
279
+ response = http.get('/api/tags')
280
+ !response.is_a?(Net::HTTPServerError)
281
+ end
282
+ rescue StandardError
283
+ false
284
+ end
285
+ private_class_method :ollama_reachable?
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../storage/inapplicable_backend'
4
+
5
+ module Woods
6
+ module MCP
7
+ # Base class for all bootstrap-time failures in the MCP server.
8
+ #
9
+ # Inherits directly from {Woods::Error} — a sibling of {Woods::ConfigurationError},
10
+ # not a child. Artifact/runtime state errors grouped here do not describe
11
+ # declared-configuration problems, so grouping under ConfigurationError would
12
+ # mislead host apps that rescue it.
13
+ #
14
+ # Each subclass carries a +details+ hash with structured context for operator
15
+ # messages. The +exe/woods-mcp+ top-level rescue formats this into a one-line
16
+ # hint.
17
+ #
18
+ # @example Rescue in an MCP entry point
19
+ # rescue Woods::MCP::BootstrapError => e
20
+ # warn "woods-mcp: #{e.class}: #{e.message}"
21
+ # exit 2
22
+ class BootstrapError < Woods::Error
23
+ # @return [Hash] Structured context for operator diagnostics
24
+ attr_reader :details
25
+
26
+ # @param message [String]
27
+ # @param details [Hash] Structured context (artifact paths, versions, etc.)
28
+ def initialize(message = nil, details: {})
29
+ super(message)
30
+ @details = details
31
+ end
32
+ end
33
+
34
+ # Raised when a required credential (e.g. OpenAI API key) is absent and
35
+ # cannot be resolved from the config snapshot or environment.
36
+ #
37
+ # @example
38
+ # raise Woods::MCP::MissingCredential.new(
39
+ # "OPENAI_API_KEY is required for OpenAI provider",
40
+ # details: { credential: "OPENAI_API_KEY", provider: "openai" }
41
+ # )
42
+ class MissingCredential < BootstrapError; end
43
+
44
+ # Raised when a stored config snapshot contradicts the active host config
45
+ # in a way that cannot be reconciled (e.g. provider class mismatch).
46
+ #
47
+ # @example
48
+ # raise Woods::MCP::ConfigMismatch.new(
49
+ # "Stored config uses Ollama but host config specifies OpenAI",
50
+ # details: { stored: "Ollama", host: "OpenAI" }
51
+ # )
52
+ class ConfigMismatch < BootstrapError; end
53
+
54
+ # Raised when the dimension recorded in the config snapshot or vector dump
55
+ # does not match the dimension reported by the active embedding provider.
56
+ #
57
+ # @example
58
+ # raise Woods::MCP::DimensionMismatch.new(
59
+ # "Provider dimension 1536 does not match stored dimension 768",
60
+ # details: { expected: 768, actual: 1536 }
61
+ # )
62
+ class DimensionMismatch < BootstrapError; end
63
+
64
+ # Raised when the +schema_version+ in +woods.json+ or a dump file is
65
+ # newer than the maximum version this gem release supports, or when the
66
+ # artifact header is corrupted and cannot be parsed.
67
+ #
68
+ # @example
69
+ # raise Woods::MCP::UnsupportedArtifact.new(
70
+ # "woods.json schema_version 3 > supported max 1",
71
+ # details: { artifact_version: 3, max_supported: 1, path: config_path.to_s }
72
+ # )
73
+ class UnsupportedArtifact < BootstrapError; end
74
+
75
+ # Raised when +woods.json+ is absent from +output_dir+ and the env flag
76
+ # +WOODS_ALLOW_AUTODETECT+ is not set. Hosts that have never run an embed
77
+ # see a clear failure message rather than silent degradation.
78
+ #
79
+ # @example
80
+ # raise Woods::MCP::MissingArtifact.new(
81
+ # "No woods.json found in /app/tmp/woods. Run `rake woods:embed` first.",
82
+ # details: { output_dir: "/app/tmp/woods" }
83
+ # )
84
+ class MissingArtifact < BootstrapError; end
85
+
86
+ # Raised when the configured embedding provider cannot be reached at boot.
87
+ # This is a **recoverable** sibling of {BootstrapError} — it is deliberately
88
+ # outside the BootstrapError hierarchy because the MCP server starts degraded
89
+ # and retries on first query rather than refusing to start.
90
+ #
91
+ # {Bootstrapper} catches this internally; nothing upstream should rescue it.
92
+ #
93
+ # @example
94
+ # raise Woods::MCP::ProviderUnreachable.new(
95
+ # "http://host.docker.internal:11434 refused connection",
96
+ # details: { url: "http://host.docker.internal:11434", reason: "connection refused" }
97
+ # )
98
+ class ProviderUnreachable < Woods::Error
99
+ # @return [String] URL that was probed
100
+ attr_reader :url
101
+ # @return [String] Machine-readable failure reason (e.g. "connection_refused",
102
+ # "timeout", "http_500", "unauthorized", "dns_failure")
103
+ attr_reader :reason
104
+ # @return [Hash] Structured context forwarded to operators for diagnosis
105
+ attr_reader :details
106
+
107
+ # Supports two call styles:
108
+ #
109
+ # Woods::MCP::ProviderUnreachable.new(url: "http://...", reason: "timeout")
110
+ # Woods::MCP::ProviderUnreachable.new("message", details: { ... })
111
+ #
112
+ # The kwarg form is preferred — probes have url/reason in hand and the
113
+ # message is derived. The positional form exists for callers that
114
+ # already have a formatted message.
115
+ def initialize(message = nil, url: nil, reason: nil, details: {})
116
+ @url = url || details[:url] || details['url']
117
+ @reason = reason || details[:reason] || details['reason']
118
+ @details = details.dup
119
+ @details[:url] = @url if @url && !@details.key?(:url) && !@details.key?('url')
120
+ @details[:reason] = @reason if @reason && !@details.key?(:reason) && !@details.key?('reason')
121
+ super(message || default_message)
122
+ end
123
+
124
+ private
125
+
126
+ def default_message
127
+ return "#{@url}: #{@reason}" if @url && @reason
128
+ return "provider unreachable (#{@reason})" if @reason
129
+
130
+ 'provider unreachable'
131
+ end
132
+ end
133
+ end
134
+ end