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
data/lib/woods/mcp/server.rb
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
3
4
|
require 'json'
|
|
4
5
|
require 'logger'
|
|
5
6
|
require 'mcp'
|
|
6
7
|
require 'open3'
|
|
7
8
|
require 'time'
|
|
8
9
|
require 'set'
|
|
10
|
+
require 'uri'
|
|
11
|
+
require_relative '../atomic_file'
|
|
12
|
+
require_relative '../generation'
|
|
9
13
|
require_relative '../tasks'
|
|
14
|
+
require_relative '../watch/status'
|
|
10
15
|
require_relative '../filename_utils'
|
|
11
16
|
require_relative '../update_check'
|
|
17
|
+
require_relative 'bootstrap_state'
|
|
18
|
+
require_relative 'errors'
|
|
12
19
|
require_relative 'index_reader'
|
|
20
|
+
require_relative 'index_reader_pinning'
|
|
21
|
+
require_relative 'protocol_policy'
|
|
22
|
+
require_relative 'tasks/extension'
|
|
23
|
+
require_relative 'tasks/request_capture'
|
|
24
|
+
require_relative 'tasks/store'
|
|
25
|
+
require_relative 'tool_contract'
|
|
13
26
|
require_relative 'tool_response_renderer'
|
|
14
27
|
require_relative 'version_aware_tool_dispatch'
|
|
15
28
|
|
|
@@ -39,6 +52,25 @@ module Woods
|
|
|
39
52
|
@pipeline_mutex = Mutex.new
|
|
40
53
|
@pipeline_in_flight = {}
|
|
41
54
|
|
|
55
|
+
# Seconds `pipeline_extract` will wait for the on-disk extraction lock
|
|
56
|
+
# before reporting contention (#170). Deliberately short: the tool
|
|
57
|
+
# answers a live agent, and "another writer is mid-run, retry" is a
|
|
58
|
+
# better answer than a multi-minute stall (the rake writers wait
|
|
59
|
+
# `LOCK_STALE_TIMEOUT` because a human started them and asked them to
|
|
60
|
+
# finish). Module-level rather than inside `class << self`, so specs can
|
|
61
|
+
# stub it and lexical lookup from the tool definitions still finds it.
|
|
62
|
+
PIPELINE_LOCK_WAIT = 2.0
|
|
63
|
+
|
|
64
|
+
# Human-readable pipeline names for the `already_running` message.
|
|
65
|
+
PIPELINE_LABELS = { extraction: 'Extraction', embedding: 'Embedding' }.freeze
|
|
66
|
+
|
|
67
|
+
# How many traversal nodes `dependents` and `dependencies` return when
|
|
68
|
+
# the caller names no limit. A hub at the default depth 2 used to return
|
|
69
|
+
# about 178 KB with nothing saying it had been anything but complete. The
|
|
70
|
+
# controls that actually shrink the answer are `depth`, `types` and
|
|
71
|
+
# `via`; `limit` and `offset` only page what those leave (B-183).
|
|
72
|
+
DEFAULT_TRAVERSAL_LIMIT = 50
|
|
73
|
+
|
|
42
74
|
class << self
|
|
43
75
|
# Build a configured MCP::Server with all tools and resources.
|
|
44
76
|
#
|
|
@@ -70,10 +102,11 @@ module Woods
|
|
|
70
102
|
respond_err = method(:error_response)
|
|
71
103
|
op_missing = lambda do |tool|
|
|
72
104
|
error_response(
|
|
73
|
-
'Pipeline operator is not configured. Pass `operator:`
|
|
74
|
-
'
|
|
105
|
+
'Pipeline operator is not configured. Pass `operator:` (a StatusReporter, ' \
|
|
106
|
+
'ErrorEscalator, and PipelineGuard) to Woods::MCP::Server.build when embedding ' \
|
|
107
|
+
'the server — neither packaged executable wires one today.',
|
|
75
108
|
code: :not_configured, config_key: 'operator',
|
|
76
|
-
doc_link: 'docs/
|
|
109
|
+
doc_link: 'docs/MCP_TOOL_COOKBOOK.md#conditional-tools--wiring', tool: tool
|
|
77
110
|
)
|
|
78
111
|
end
|
|
79
112
|
fb_missing = lambda do |tool|
|
|
@@ -81,7 +114,7 @@ module Woods
|
|
|
81
114
|
'Feedback store is not configured. Pass `feedback_store:` to Woods::MCP::Server.build ' \
|
|
82
115
|
'to enable retrieval feedback capture.',
|
|
83
116
|
code: :not_configured, config_key: 'feedback_store',
|
|
84
|
-
doc_link: 'docs/
|
|
117
|
+
doc_link: 'docs/MCP_TOOL_COOKBOOK.md#conditional-tools--wiring', tool: tool
|
|
85
118
|
)
|
|
86
119
|
end
|
|
87
120
|
snap_missing = lambda do |tool|
|
|
@@ -89,7 +122,7 @@ module Woods
|
|
|
89
122
|
'Snapshot store is not configured. Set `enable_snapshots: true` in Woods.configure ' \
|
|
90
123
|
'and pass `snapshot_store:` to Woods::MCP::Server.build.',
|
|
91
124
|
code: :not_configured, config_key: 'enable_snapshots',
|
|
92
|
-
doc_link: 'docs/
|
|
125
|
+
doc_link: 'docs/MCP_TOOL_COOKBOOK.md#conditional-tools--wiring', tool: tool
|
|
93
126
|
)
|
|
94
127
|
end
|
|
95
128
|
|
|
@@ -97,22 +130,41 @@ module Woods
|
|
|
97
130
|
name: 'woods',
|
|
98
131
|
version: Woods::VERSION,
|
|
99
132
|
resources: resources,
|
|
100
|
-
resource_templates: resource_templates
|
|
133
|
+
resource_templates: resource_templates,
|
|
134
|
+
configuration: ::MCP::Configuration.new.merge(::MCP.configuration),
|
|
135
|
+
**ProtocolPolicy.cache_hints
|
|
101
136
|
)
|
|
102
137
|
# Rewrite "Tool not found" into version-aware update guidance for agents
|
|
103
138
|
# running against an older gem than the skill they're following assumes.
|
|
104
139
|
server.singleton_class.prepend(VersionAwareToolDispatch)
|
|
140
|
+
# Make the per-request Tasks opt-in reachable from a tool handler,
|
|
141
|
+
# which otherwise only sees `arguments`.
|
|
142
|
+
server.singleton_class.prepend(Tasks::RequestCapture)
|
|
143
|
+
|
|
144
|
+
# The Tasks extension backs the two long-running tools. Registered
|
|
145
|
+
# unconditionally rather than only alongside `operator`, because
|
|
146
|
+
# `tasks/get` must keep answering for a handle minted by a *previous*
|
|
147
|
+
# process — the crash-resilience case is precisely the one where this
|
|
148
|
+
# server was restarted and may come up wired differently.
|
|
149
|
+
task_store = Tasks::Store.new(index_dir)
|
|
150
|
+
Tasks::Extension.install(server, store: task_store)
|
|
105
151
|
|
|
106
152
|
define_lookup_tool(server, reader, respond, respond_err, renderer)
|
|
107
153
|
define_search_tool(server, reader, respond, respond_err, renderer)
|
|
108
154
|
define_traversal_tool(server, reader, respond, renderer,
|
|
109
155
|
name: 'dependencies',
|
|
110
|
-
description: 'Traverse forward dependencies of a unit (what it depends on).
|
|
156
|
+
description: 'Traverse forward dependencies of a unit (what it depends on). ' \
|
|
157
|
+
'Narrow with depth, types and via first: they shrink the answer, ' \
|
|
158
|
+
'while limit and offset only page it. Returns a BFS tree with ' \
|
|
159
|
+
"depth, bounded to #{DEFAULT_TRAVERSAL_LIMIT} nodes by default.",
|
|
111
160
|
reader_method: :traverse_dependencies,
|
|
112
161
|
render_key: :dependencies)
|
|
113
162
|
define_traversal_tool(server, reader, respond, renderer,
|
|
114
163
|
name: 'dependents',
|
|
115
|
-
description: 'Traverse reverse dependencies of a unit (what depends on it).
|
|
164
|
+
description: 'Traverse reverse dependencies of a unit (what depends on it). ' \
|
|
165
|
+
'Narrow with depth, types and via first: they shrink the answer, ' \
|
|
166
|
+
'while limit and offset only page it. Returns a BFS tree with ' \
|
|
167
|
+
"depth, bounded to #{DEFAULT_TRAVERSAL_LIMIT} nodes by default.",
|
|
116
168
|
reader_method: :traverse_dependents,
|
|
117
169
|
render_key: :dependents)
|
|
118
170
|
define_structure_tool(server, reader, respond, renderer)
|
|
@@ -121,9 +173,9 @@ module Woods
|
|
|
121
173
|
define_pagerank_tool(server, reader, respond, renderer)
|
|
122
174
|
define_framework_tool(server, reader, respond, renderer)
|
|
123
175
|
define_recent_changes_tool(server, reader, respond, renderer)
|
|
124
|
-
define_reload_tool(server, reader, respond, retriever_reloader)
|
|
125
|
-
define_retrieve_tool(server, retriever, respond, respond_err)
|
|
126
|
-
define_trace_flow_tool(server, reader,
|
|
176
|
+
define_reload_tool(server, reader, respond, respond_err, retriever_reloader, bootstrap_state)
|
|
177
|
+
define_retrieve_tool(server, retriever, respond, respond_err, bootstrap_state)
|
|
178
|
+
define_trace_flow_tool(server, reader, respond, respond_err, renderer)
|
|
127
179
|
# Conditionally register collaborator-dependent tools. Historically
|
|
128
180
|
# all 15 stubs were registered unconditionally and returned
|
|
129
181
|
# isError: true when the wiring was missing — that added token
|
|
@@ -131,14 +183,19 @@ module Woods
|
|
|
131
183
|
# try tools guaranteed to fail. Only register when the collaborator
|
|
132
184
|
# is wired, so tools/list reflects what the server can actually do.
|
|
133
185
|
define_session_trace_tool(server, reader, respond, respond_err) if session_tracer_wired?
|
|
134
|
-
define_operator_tools(server, operator, respond, respond_err, op_missing) if operator
|
|
186
|
+
define_operator_tools(server, operator, respond, respond_err, op_missing, task_store) if operator
|
|
135
187
|
define_feedback_tools(server, feedback_store, respond, respond_err, fb_missing) if feedback_store
|
|
136
188
|
define_snapshot_tools(server, snapshot_store, respond, respond_err, snap_missing) if snapshot_store
|
|
137
189
|
define_notion_sync_tool(server, reader, index_dir, respond, respond_err) if notion_wired?
|
|
138
190
|
define_woods_status_tool(server, reader, retriever, index_dir, bootstrap_state, respond)
|
|
139
191
|
register_resource_handler(server, reader)
|
|
192
|
+
ToolContract.apply!(server)
|
|
193
|
+
IndexReaderPinning.install(server, reader: reader)
|
|
140
194
|
|
|
141
|
-
|
|
195
|
+
# Last, after every conditional registration above — the whole point is
|
|
196
|
+
# that a host with Notion wired advertises the same tool order as one
|
|
197
|
+
# without it.
|
|
198
|
+
ProtocolPolicy.sort_tools!(server)
|
|
142
199
|
end
|
|
143
200
|
|
|
144
201
|
private
|
|
@@ -168,7 +225,7 @@ module Woods
|
|
|
168
225
|
|
|
169
226
|
# Notion export needs both an API token and at least one database ID.
|
|
170
227
|
# A non-blank NOTION_API_TOKEN env var overrides the config token (see
|
|
171
|
-
# docs/
|
|
228
|
+
# docs/NOTION_INTEGRATION.md). Resolution goes through
|
|
172
229
|
# Woods.resolve_notion_token so a blank env var is treated as absent
|
|
173
230
|
# (rather than masking a valid configured token) — matching the
|
|
174
231
|
# exporter and the notion_sync handler.
|
|
@@ -182,7 +239,17 @@ module Woods
|
|
|
182
239
|
end
|
|
183
240
|
|
|
184
241
|
def text_response(text)
|
|
185
|
-
|
|
242
|
+
structured = { text: text }
|
|
243
|
+
structured[:data] = JSON.parse(text)
|
|
244
|
+
::MCP::Tool::Response.new(
|
|
245
|
+
[{ type: 'text', text: text }],
|
|
246
|
+
structured_content: structured
|
|
247
|
+
)
|
|
248
|
+
rescue JSON::ParserError
|
|
249
|
+
::MCP::Tool::Response.new(
|
|
250
|
+
[{ type: 'text', text: text }],
|
|
251
|
+
structured_content: structured
|
|
252
|
+
)
|
|
186
253
|
end
|
|
187
254
|
|
|
188
255
|
# Build a structured error response that carries machine-readable
|
|
@@ -203,6 +270,7 @@ module Woods
|
|
|
203
270
|
::MCP::Tool::Response.new(
|
|
204
271
|
[{ type: 'text', text: message }],
|
|
205
272
|
error: true,
|
|
273
|
+
structured_content: { text: message },
|
|
206
274
|
meta: meta
|
|
207
275
|
)
|
|
208
276
|
end
|
|
@@ -261,7 +329,7 @@ module Woods
|
|
|
261
329
|
# Load a precomputed flow document written by FlowPrecomputer, when
|
|
262
330
|
# `config.precompute_flows` was enabled during extraction. Returns nil
|
|
263
331
|
# when the entry point is missing a method suffix, the JSON file isn't
|
|
264
|
-
# on disk, or the file can't be parsed — callers fall back to
|
|
332
|
+
# on disk, or the file can't be read or parsed — callers fall back to
|
|
265
333
|
# FlowAssembler.
|
|
266
334
|
#
|
|
267
335
|
# @param index_dir [String]
|
|
@@ -278,11 +346,27 @@ module Woods
|
|
|
278
346
|
# flows/) using the SAME transform FlowPrecomputer writes with, so a
|
|
279
347
|
# legitimately precomputed flow always resolves to the file on disk.
|
|
280
348
|
filename = Woods::FilenameUtils.flow_filename(controller, action)
|
|
349
|
+
# The path is derived from the entry point and joined against THIS
|
|
350
|
+
# process's index_dir — the path *values* in flow_index.json /
|
|
351
|
+
# metadata[:flow_paths] are never consulted. That is what keeps
|
|
352
|
+
# both formats working unchanged: post-#190 indexes persist
|
|
353
|
+
# output_dir-relative values ("flows/X_y.json"), while pre-#190
|
|
354
|
+
# indexes persisted the extraction machine's absolute paths (e.g.
|
|
355
|
+
# container-side "/app/tmp/woods/flows/X_y.json"), which need not
|
|
356
|
+
# resolve on the reading host at all.
|
|
281
357
|
path = File.join(index_dir, 'flows', filename)
|
|
282
358
|
return nil unless File.exist?(path)
|
|
283
359
|
|
|
284
|
-
|
|
285
|
-
|
|
360
|
+
# AtomicFile.read, not File.read: flow documents carry free-text
|
|
361
|
+
# (args_hint / condition strings) that can be non-ASCII, and a bare
|
|
362
|
+
# read under LANG=C tags the result US-ASCII so the first
|
|
363
|
+
# JSON.parse raises Encoding::InvalidByteSequenceError.
|
|
364
|
+
Woods::FlowDocument.from_h(JSON.parse(Woods::AtomicFile.read(path)))
|
|
365
|
+
rescue JSON::ParserError, Errno::ENOENT, EncodingError
|
|
366
|
+
# EncodingError included so ANY unreadable precomputed flow (torn,
|
|
367
|
+
# corrupt, mis-encoded bytes) degrades to query-time reassembly as
|
|
368
|
+
# documented, instead of escaping to trace_flow's generic handler
|
|
369
|
+
# as an internal_error.
|
|
286
370
|
nil
|
|
287
371
|
end
|
|
288
372
|
|
|
@@ -307,6 +391,37 @@ module Woods
|
|
|
307
391
|
container["#{key}_offset"] = offset if offset.positive?
|
|
308
392
|
end
|
|
309
393
|
|
|
394
|
+
# Page a traversal result's `nodes` hash in place, in BFS order.
|
|
395
|
+
#
|
|
396
|
+
# Mirrors {#paginate_section}'s metadata keys (`nodes_total`,
|
|
397
|
+
# `nodes_truncated`, `nodes_offset`) so both renderers print the one
|
|
398
|
+
# truncation line they already had for `graph_analysis`. A page that
|
|
399
|
+
# holds every node adds no keys at all, so a small result renders
|
|
400
|
+
# exactly as it did before the bound existed (B-183).
|
|
401
|
+
#
|
|
402
|
+
# `nodes_total` marks *any* partial answer, not only one with more
|
|
403
|
+
# behind it. Keying it on `total > offset + limit` left the last page
|
|
404
|
+
# of a walk indistinguishable from a complete one: 21 nodes of 121,
|
|
405
|
+
# with nothing saying 100 were skipped. `nodes_truncated` still means
|
|
406
|
+
# what it always did, "there is more after this page".
|
|
407
|
+
#
|
|
408
|
+
# @param result [Hash] traversal result, mutated
|
|
409
|
+
# @param limit [Integer] maximum nodes to retain
|
|
410
|
+
# @param offset [Integer] nodes to skip from the front
|
|
411
|
+
# @return [void]
|
|
412
|
+
def paginate_traversal_nodes(result, limit, offset)
|
|
413
|
+
nodes = result[:nodes]
|
|
414
|
+
return unless nodes.is_a?(Hash)
|
|
415
|
+
|
|
416
|
+
total = nodes.size
|
|
417
|
+
return if offset.zero? && total <= limit
|
|
418
|
+
|
|
419
|
+
result[:nodes] = nodes.to_a.drop(offset).take(limit).to_h
|
|
420
|
+
result[:nodes_total] = total if offset.positive? || total > limit
|
|
421
|
+
result[:nodes_truncated] = true if total > offset + limit
|
|
422
|
+
result[:nodes_offset] = offset if offset.positive?
|
|
423
|
+
end
|
|
424
|
+
|
|
310
425
|
def define_lookup_tool(server, reader, respond, respond_err, renderer)
|
|
311
426
|
coerce = method(:coerce_array)
|
|
312
427
|
server.define_tool(
|
|
@@ -386,7 +501,7 @@ module Woods
|
|
|
386
501
|
description: 'Restrict scan to these unit types: model, controller, service, job, mailer, etc.'
|
|
387
502
|
},
|
|
388
503
|
fields: {
|
|
389
|
-
type: 'array', items: { type: 'string' },
|
|
504
|
+
type: 'array', items: { type: 'string', enum: %w[identifier metadata source_code] },
|
|
390
505
|
description: 'Fields to search: identifier (default), source_code, metadata'
|
|
391
506
|
},
|
|
392
507
|
limit: { type: 'integer', description: 'Maximum results (default: 20)' },
|
|
@@ -440,6 +555,7 @@ module Woods
|
|
|
440
555
|
def define_traversal_tool(server, reader, respond, renderer, name:, description:, reader_method:, render_key:)
|
|
441
556
|
coerce = method(:coerce_array)
|
|
442
557
|
coerce_int = method(:coerce_integer)
|
|
558
|
+
paginate_nodes = method(:paginate_traversal_nodes)
|
|
443
559
|
server.define_tool(
|
|
444
560
|
name: name,
|
|
445
561
|
description: description,
|
|
@@ -452,25 +568,34 @@ module Woods
|
|
|
452
568
|
description: 'Filter to these types'
|
|
453
569
|
},
|
|
454
570
|
via: {
|
|
455
|
-
|
|
571
|
+
anyOf: [
|
|
572
|
+
{ type: 'string' },
|
|
573
|
+
{ type: 'array', items: { type: 'string' } }
|
|
574
|
+
],
|
|
456
575
|
description: 'Filter by relationship type. Accepts either a single string ' \
|
|
457
576
|
"(e.g. 'code_reference') or an array " \
|
|
458
577
|
"(e.g. ['code_reference','render']); both forms are coerced to an array internally. " \
|
|
459
578
|
'Known values: link_to, redirect_to, form_action, render, code_reference, ' \
|
|
460
|
-
'belongs_to, has_many, has_one, has_and_belongs_to_many.'
|
|
461
|
-
}
|
|
579
|
+
'belongs_to, has_many, has_one, has_and_belongs_to_many, polymorphic_interface.'
|
|
580
|
+
},
|
|
581
|
+
limit: { type: 'integer',
|
|
582
|
+
description: "Maximum nodes to return (default: #{DEFAULT_TRAVERSAL_LIMIT})" },
|
|
583
|
+
offset: { type: 'integer', description: 'Skip this many nodes (default: 0)' }
|
|
462
584
|
},
|
|
463
585
|
required: ['identifier']
|
|
464
586
|
}
|
|
465
|
-
) do |identifier:, server_context:, depth: nil, types: nil, via: nil|
|
|
587
|
+
) do |identifier:, server_context:, depth: nil, types: nil, via: nil, limit: nil, offset: nil|
|
|
466
588
|
types = coerce.call(types)
|
|
467
589
|
via = coerce.call(via)
|
|
468
590
|
depth = coerce_int.call(depth)
|
|
591
|
+
limit = coerce_int.call(limit)
|
|
592
|
+
offset = coerce_int.call(offset)
|
|
469
593
|
result = reader.send(reader_method, identifier, depth: depth || 2, types: types, via: via)
|
|
470
594
|
if result[:found] == false
|
|
471
595
|
result[:message] =
|
|
472
596
|
"Identifier '#{identifier}' not found in the index. Use 'search' to find valid identifiers."
|
|
473
597
|
end
|
|
598
|
+
paginate_nodes.call(result, limit || DEFAULT_TRAVERSAL_LIMIT, offset || 0)
|
|
474
599
|
respond.call(renderer.render(render_key, result))
|
|
475
600
|
end
|
|
476
601
|
end
|
|
@@ -499,12 +624,16 @@ module Woods
|
|
|
499
624
|
coerce_int = method(:coerce_integer)
|
|
500
625
|
server.define_tool(
|
|
501
626
|
name: 'graph_analysis',
|
|
502
|
-
description: 'Get structural analysis of the dependency graph: orphans, dead ends, hubs, cycles,
|
|
627
|
+
description: 'Get structural analysis of the dependency graph: orphans, dead ends, hubs, cycles, bridges, ' \
|
|
628
|
+
'cross_database_edges (associations and foreign keys across databases), ' \
|
|
629
|
+
'volatile_dependencies (edges into units that change far more often than the dependent), ' \
|
|
630
|
+
'and undeclared_package_edges (edges that cross a package boundary the source package ' \
|
|
631
|
+
'never declared).',
|
|
503
632
|
input_schema: {
|
|
504
633
|
properties: {
|
|
505
634
|
analysis: {
|
|
506
635
|
type: 'string',
|
|
507
|
-
enum: %w[
|
|
636
|
+
enum: ToolResponseRenderer::GRAPH_ANALYSIS_SECTIONS + %w[all],
|
|
508
637
|
description: 'Which analysis to return. Default: all'
|
|
509
638
|
},
|
|
510
639
|
limit: { type: 'integer', description: 'Limit results per section (default: 20)' },
|
|
@@ -521,7 +650,7 @@ module Woods
|
|
|
521
650
|
result = if section == 'all'
|
|
522
651
|
if limit || effective_offset.positive?
|
|
523
652
|
truncated = data.dup
|
|
524
|
-
|
|
653
|
+
ToolResponseRenderer::GRAPH_ANALYSIS_SECTIONS.each do |key|
|
|
525
654
|
paginate.call(truncated, key, limit, effective_offset)
|
|
526
655
|
end
|
|
527
656
|
truncated
|
|
@@ -529,7 +658,7 @@ module Woods
|
|
|
529
658
|
data
|
|
530
659
|
end
|
|
531
660
|
else
|
|
532
|
-
single = { section => data[section], 'stats' => data['stats'] }
|
|
661
|
+
single = { section => data[section] || [], 'stats' => data['stats'] }
|
|
533
662
|
paginate.call(single, section, limit, effective_offset) if limit || effective_offset.positive?
|
|
534
663
|
single
|
|
535
664
|
end
|
|
@@ -665,38 +794,86 @@ module Woods
|
|
|
665
794
|
end
|
|
666
795
|
end
|
|
667
796
|
|
|
668
|
-
def define_reload_tool(server, reader, respond, retriever_reloader)
|
|
797
|
+
def define_reload_tool(server, reader, respond, respond_err, retriever_reloader, bootstrap_state)
|
|
669
798
|
server.define_tool(
|
|
670
799
|
name: 'reload',
|
|
671
800
|
description: 'Reload extraction data from disk. Use after re-running extraction or woods:embed to pick ' \
|
|
672
801
|
'up changes without restarting the server. Refreshes the JSON index (manifest, dependency ' \
|
|
673
802
|
'graph, unit cache) AND re-hydrates the retriever\'s in-memory vector/metadata/graph ' \
|
|
674
|
-
'stores from the latest dumps.
|
|
675
|
-
'
|
|
803
|
+
'stores from the latest dumps. The refresh is transactional (build-then-swap): candidate ' \
|
|
804
|
+
'stores are built off-side, and on any failure nothing is swapped — the previous ' \
|
|
805
|
+
'generation keeps being served and a degraded_index error names it. Durable backends ' \
|
|
806
|
+
'(pgvector, Qdrant) are auto-refreshed externally — their counts in the response reflect ' \
|
|
807
|
+
'the read-through state.',
|
|
676
808
|
input_schema: { type: 'object', properties: {} }
|
|
677
809
|
) do |server_context:|
|
|
678
|
-
reader.reload!
|
|
679
|
-
manifest = reader.manifest
|
|
680
|
-
payload = {
|
|
681
|
-
reloaded: true,
|
|
682
|
-
extracted_at: manifest['extracted_at'],
|
|
683
|
-
total_units: manifest['total_units'],
|
|
684
|
-
counts: manifest['counts']
|
|
685
|
-
}
|
|
686
810
|
if retriever_reloader
|
|
687
|
-
begin
|
|
688
|
-
|
|
811
|
+
counts = begin
|
|
812
|
+
retriever_reloader.call(reader)
|
|
813
|
+
rescue Woods::MCP::ReloadDegraded => e
|
|
814
|
+
reason = "#{e.class}: #{e.message}"
|
|
815
|
+
bootstrap_state&.record_reload_failure(generation: e.generation, stores: e.stores, reason: reason)
|
|
816
|
+
next respond_err.call(
|
|
817
|
+
"Reload failed; nothing was swapped. Generation #{e.generation} is still being served: " \
|
|
818
|
+
"#{reason} Fix the underlying store or index issue, then invoke reload again.",
|
|
819
|
+
code: :degraded_index,
|
|
820
|
+
tool: 'reload',
|
|
821
|
+
degraded: true,
|
|
822
|
+
phase: 'reload',
|
|
823
|
+
generation: e.generation,
|
|
824
|
+
stores: e.stores,
|
|
825
|
+
reason: reason
|
|
826
|
+
)
|
|
689
827
|
rescue StandardError => e
|
|
690
|
-
|
|
828
|
+
# A foreign error from a custom reloader leaves the reader and
|
|
829
|
+
# stores untouched too (the transaction is all-or-nothing), so
|
|
830
|
+
# it maps to the same reload-phase degraded shape with best-
|
|
831
|
+
# effort fields.
|
|
832
|
+
generation = reader.loaded_generation || 0
|
|
833
|
+
reason = "#{e.class}: #{e.message}"
|
|
834
|
+
bootstrap_state&.record_reload_failure(generation: generation,
|
|
835
|
+
stores: %w[vector metadata graph], reason: reason)
|
|
836
|
+
next respond_err.call(
|
|
837
|
+
"Reload failed; nothing was swapped. Generation #{generation} is still being served: " \
|
|
838
|
+
"#{reason} Fix the underlying store or index issue, then invoke reload again.",
|
|
839
|
+
code: :degraded_index,
|
|
840
|
+
tool: 'reload',
|
|
841
|
+
degraded: true,
|
|
842
|
+
phase: 'reload',
|
|
843
|
+
generation: generation,
|
|
844
|
+
stores: %w[vector metadata graph],
|
|
845
|
+
reason: reason
|
|
846
|
+
)
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
manifest = reader.manifest
|
|
850
|
+
payload = {
|
|
851
|
+
reloaded: true,
|
|
852
|
+
extracted_at: manifest['extracted_at'],
|
|
853
|
+
total_units: manifest['total_units'],
|
|
854
|
+
counts: manifest['counts'],
|
|
855
|
+
retriever: counts
|
|
856
|
+
}
|
|
857
|
+
respond.call(JSON.pretty_generate(payload))
|
|
858
|
+
else
|
|
859
|
+
reader.with_exclusive_reload do |manifest|
|
|
860
|
+
payload = {
|
|
861
|
+
reloaded: true,
|
|
862
|
+
extracted_at: manifest['extracted_at'],
|
|
863
|
+
total_units: manifest['total_units'],
|
|
864
|
+
counts: manifest['counts']
|
|
865
|
+
}
|
|
866
|
+
respond.call(JSON.pretty_generate(payload))
|
|
691
867
|
end
|
|
692
868
|
end
|
|
693
|
-
respond.call(JSON.pretty_generate(payload))
|
|
694
869
|
end
|
|
695
870
|
end
|
|
696
871
|
|
|
697
|
-
def define_retrieve_tool(server, retriever, respond, respond_err)
|
|
872
|
+
def define_retrieve_tool(server, retriever, respond, respond_err, bootstrap_state = nil)
|
|
698
873
|
coerce_int = method(:coerce_integer)
|
|
699
874
|
coerce = method(:coerce_array)
|
|
875
|
+
stale_check = method(:stale_index_result?)
|
|
876
|
+
degraded_response = method(:degraded_retrieval_response)
|
|
700
877
|
server.define_tool(
|
|
701
878
|
name: 'codebase_retrieve',
|
|
702
879
|
description: 'Semantic search: retrieve relevant code units for a natural-language question. ' \
|
|
@@ -757,13 +934,47 @@ module Woods
|
|
|
757
934
|
budget = coerce_int.call(budget)
|
|
758
935
|
types = coerce.call(types)
|
|
759
936
|
exclude_types = coerce.call(exclude_types)
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
937
|
+
# M6: a hydration failure at boot left the in-memory stores
|
|
938
|
+
# empty. Every query would come back as a clean empty result —
|
|
939
|
+
# indistinguishable from "no matches" — so surface the degraded
|
|
940
|
+
# state as typed metadata instead of answering with nothing.
|
|
941
|
+
if bootstrap_state&.hydration_failed?
|
|
942
|
+
failures = bootstrap_state.hydration_failures
|
|
943
|
+
next degraded_response.call(
|
|
944
|
+
respond_err,
|
|
945
|
+
reason: failures.values.map { |e| "#{e.class}: #{e.message}" }.join('; '),
|
|
946
|
+
stores: failures.keys.map(&:to_s),
|
|
947
|
+
phase: 'boot'
|
|
766
948
|
)
|
|
949
|
+
end
|
|
950
|
+
if retriever
|
|
951
|
+
begin
|
|
952
|
+
result = retriever.retrieve(
|
|
953
|
+
query,
|
|
954
|
+
budget: budget || 8000,
|
|
955
|
+
types: types,
|
|
956
|
+
exclude_types: exclude_types
|
|
957
|
+
)
|
|
958
|
+
rescue Woods::Retriever::StoreError => e
|
|
959
|
+
# M8: a metadata-store failure mid-query must not surface as
|
|
960
|
+
# a raw raise through the tool boundary (or as the misleading
|
|
961
|
+
# :absent / empty answers the retriever used to produce).
|
|
962
|
+
next degraded_response.call(
|
|
963
|
+
respond_err,
|
|
964
|
+
reason: e.message,
|
|
965
|
+
stores: [e.store],
|
|
966
|
+
phase: 'query'
|
|
967
|
+
)
|
|
968
|
+
end
|
|
969
|
+
if stale_check.call(result)
|
|
970
|
+
next respond_err.call(
|
|
971
|
+
'The vector index appears stale: matches were found but their source data is ' \
|
|
972
|
+
'missing (likely a deleted or renamed unit). Re-run `woods:embed` (or ' \
|
|
973
|
+
'`woods:embed_incremental`) to refresh the index, then retry.',
|
|
974
|
+
code: :stale_index,
|
|
975
|
+
tool: 'codebase_retrieve'
|
|
976
|
+
)
|
|
977
|
+
end
|
|
767
978
|
respond.call(result.context)
|
|
768
979
|
else
|
|
769
980
|
respond_err.call(
|
|
@@ -780,7 +991,51 @@ module Woods
|
|
|
780
991
|
end
|
|
781
992
|
end
|
|
782
993
|
|
|
783
|
-
|
|
994
|
+
# Detect a stale vector index: candidates matched the query but every
|
|
995
|
+
# one of them pointed at a unit the metadata store no longer has
|
|
996
|
+
# (deleted/renamed since the last embed). Distinguishes that case
|
|
997
|
+
# from a genuine "no matches" so the tool can say what happened
|
|
998
|
+
# instead of returning near-empty context as clean success.
|
|
999
|
+
#
|
|
1000
|
+
# @param result [Woods::Retriever::RetrievalResult] (or a test double
|
|
1001
|
+
# with the same shape — +trace+ may be absent/nil on older doubles)
|
|
1002
|
+
# @return [Boolean]
|
|
1003
|
+
def stale_index_result?(result)
|
|
1004
|
+
trace = result.respond_to?(:trace) ? result.trace : nil
|
|
1005
|
+
return false unless trace
|
|
1006
|
+
|
|
1007
|
+
trace.ranked_count.to_i.positive? &&
|
|
1008
|
+
trace.skipped_missing_metadata.to_i.positive? &&
|
|
1009
|
+
Array(result.sources).empty?
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
# Typed degraded-metadata response for codebase_retrieve (M6/M8). A
|
|
1013
|
+
# degraded retriever must never answer with a clean empty result: the
|
|
1014
|
+
# response is a tool error carrying the machine-readable degraded
|
|
1015
|
+
# marker, which stores are affected, and the underlying reason.
|
|
1016
|
+
#
|
|
1017
|
+
# @param respond_err [Method] the tool error-response builder
|
|
1018
|
+
# @param reason [String] human-readable failure summary
|
|
1019
|
+
# @param stores [Array<String>] affected store component names
|
|
1020
|
+
# @param phase [String] 'boot' (hydration failure) or 'query'
|
|
1021
|
+
# (store failure at query time)
|
|
1022
|
+
# @return [MCP::Tool::Response]
|
|
1023
|
+
def degraded_retrieval_response(respond_err, reason:, stores:, phase:)
|
|
1024
|
+
respond_err.call(
|
|
1025
|
+
"Semantic search is degraded: #{reason}. The affected store(s) return no data, so " \
|
|
1026
|
+
'queries would come back empty — this is NOT "no results". ' \
|
|
1027
|
+
'Run `woods_status` for the bootstrap report, re-run `woods:embed` if the index is stale, ' \
|
|
1028
|
+
'and restart the server once the store is loadable.',
|
|
1029
|
+
code: :degraded_index,
|
|
1030
|
+
tool: 'codebase_retrieve',
|
|
1031
|
+
degraded: true,
|
|
1032
|
+
phase: phase,
|
|
1033
|
+
stores: stores,
|
|
1034
|
+
reason: reason
|
|
1035
|
+
)
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
def define_trace_flow_tool(server, reader, respond, respond_err, renderer)
|
|
784
1039
|
require_relative '../flow_assembler'
|
|
785
1040
|
require_relative '../flow_document'
|
|
786
1041
|
require_relative '../dependency_graph'
|
|
@@ -810,10 +1065,10 @@ module Woods
|
|
|
810
1065
|
# extraction (gated on `config.precompute_flows`) — it avoids
|
|
811
1066
|
# re-parsing source on every request. Fall back to query-time
|
|
812
1067
|
# reassembly when no precomputed document exists.
|
|
813
|
-
flow_doc = load_precomputed.call(
|
|
1068
|
+
flow_doc = load_precomputed.call(reader.payload_dir, entry_point)
|
|
814
1069
|
flow_doc ||= begin
|
|
815
1070
|
graph = reader.dependency_graph
|
|
816
|
-
assembler = Woods::FlowAssembler.new(graph: graph, extracted_dir:
|
|
1071
|
+
assembler = Woods::FlowAssembler.new(graph: graph, extracted_dir: reader.payload_dir.to_s)
|
|
817
1072
|
assembler.assemble(entry_point, max_depth: max_depth)
|
|
818
1073
|
end
|
|
819
1074
|
|
|
@@ -823,6 +1078,14 @@ module Woods
|
|
|
823
1078
|
# surface it, rather than wrapping the error payload in a
|
|
824
1079
|
# successful response — consistent with session_trace and
|
|
825
1080
|
# codebase_retrieve.
|
|
1081
|
+
if ToolContract.artifact_error?(e)
|
|
1082
|
+
next respond_err.call(
|
|
1083
|
+
'trace_flow could not read a required Index artifact.',
|
|
1084
|
+
code: :corrupt_artifact,
|
|
1085
|
+
tool: 'trace_flow'
|
|
1086
|
+
)
|
|
1087
|
+
end
|
|
1088
|
+
|
|
826
1089
|
respond_err.call(
|
|
827
1090
|
"trace_flow failed: #{e.message}",
|
|
828
1091
|
code: :internal_error,
|
|
@@ -854,7 +1117,7 @@ module Woods
|
|
|
854
1117
|
'and set `session_tracer_enabled = true` in Woods.configure.',
|
|
855
1118
|
code: :not_configured,
|
|
856
1119
|
config_key: 'session_store',
|
|
857
|
-
doc_link: 'docs/
|
|
1120
|
+
doc_link: 'docs/MCP_TOOL_COOKBOOK.md#conditional-tools--wiring',
|
|
858
1121
|
tool: 'session_trace'
|
|
859
1122
|
)
|
|
860
1123
|
end
|
|
@@ -876,9 +1139,9 @@ module Woods
|
|
|
876
1139
|
end
|
|
877
1140
|
end
|
|
878
1141
|
|
|
879
|
-
def define_operator_tools(server, operator, respond, respond_err, op_missing)
|
|
880
|
-
define_pipeline_extract_tool(server, operator, respond, respond_err, op_missing)
|
|
881
|
-
define_pipeline_embed_tool(server, operator, respond, respond_err, op_missing)
|
|
1142
|
+
def define_operator_tools(server, operator, respond, respond_err, op_missing, task_store)
|
|
1143
|
+
define_pipeline_extract_tool(server, operator, respond, respond_err, op_missing, task_store)
|
|
1144
|
+
define_pipeline_embed_tool(server, operator, respond, respond_err, op_missing, task_store)
|
|
882
1145
|
define_pipeline_status_tool(server, operator, respond, respond_err, op_missing)
|
|
883
1146
|
define_pipeline_diagnose_tool(server, operator, respond, respond_err, op_missing)
|
|
884
1147
|
define_pipeline_repair_tool(server, operator, respond, respond_err, op_missing)
|
|
@@ -891,7 +1154,8 @@ module Woods
|
|
|
891
1154
|
define_retrieval_suggest_tool(server, feedback_store, respond, fb_missing)
|
|
892
1155
|
end
|
|
893
1156
|
|
|
894
|
-
def define_pipeline_extract_tool(server, operator, respond, respond_err, op_missing)
|
|
1157
|
+
def define_pipeline_extract_tool(server, operator, respond, respond_err, op_missing, task_store)
|
|
1158
|
+
cooldown = method(:cooldown_error)
|
|
895
1159
|
server.define_tool(
|
|
896
1160
|
name: 'pipeline_extract',
|
|
897
1161
|
description: 'Trigger a codebase extraction pipeline run. Checks rate limits before proceeding.',
|
|
@@ -926,50 +1190,114 @@ module Woods
|
|
|
926
1190
|
end
|
|
927
1191
|
|
|
928
1192
|
guard = operator[:pipeline_guard]
|
|
929
|
-
if
|
|
930
|
-
next
|
|
931
|
-
'Extraction is rate-limited. Try again later.',
|
|
932
|
-
code: :rate_limited,
|
|
933
|
-
tool: 'pipeline_extract',
|
|
934
|
-
retry_after_seconds: 300
|
|
935
|
-
)
|
|
1193
|
+
if (blocked = cooldown.call(guard, :extraction, 'pipeline_extract'))
|
|
1194
|
+
next blocked
|
|
936
1195
|
end
|
|
937
1196
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1197
|
+
build_extraction_runner = lambda do |output_dir|
|
|
1198
|
+
lambda do
|
|
1199
|
+
# exe/woods-mcp deliberately loads no extraction machinery, so
|
|
1200
|
+
# Woods::Extractor is not defined in a standalone index-server
|
|
1201
|
+
# process. Resolve it here, the same lazy require Woods.extract!
|
|
1202
|
+
# uses — otherwise every pipeline_extract run dies in the
|
|
1203
|
+
# background with NameError.
|
|
1204
|
+
require_relative '../extractor' unless defined?(Woods::Extractor)
|
|
1205
|
+
extractor = Woods::Extractor.new(output_dir: output_dir)
|
|
1206
|
+
incremental ? extractor.extract_changed(files) : extractor.extract_all
|
|
1207
|
+
end
|
|
948
1208
|
end
|
|
949
1209
|
|
|
950
|
-
|
|
951
|
-
|
|
1210
|
+
next Woods::MCP::Server.send(
|
|
1211
|
+
:start_pipeline_run,
|
|
1212
|
+
kind: :extraction, tool: 'pipeline_extract',
|
|
1213
|
+
task_store: task_store, respond: respond, respond_err: respond_err,
|
|
1214
|
+
runner_builder: build_extraction_runner,
|
|
1215
|
+
started: -> { guard&.record!(:extraction) },
|
|
1216
|
+
started_message: 'Extraction pipeline started in background thread'
|
|
1217
|
+
)
|
|
1218
|
+
end
|
|
1219
|
+
end
|
|
952
1220
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1221
|
+
# The same lock every other writer against this index uses —
|
|
1222
|
+
# `woods:extract`/`incremental`/`refresh` and the watch daemon all
|
|
1223
|
+
# build it from the daemon's constants (see CLAUDE.md, "writers
|
|
1224
|
+
# serialize on PipelineLock").
|
|
1225
|
+
#
|
|
1226
|
+
# @param output_dir [String, Pathname] index directory
|
|
1227
|
+
# @return [Woods::Coordination::PipelineLock]
|
|
1228
|
+
def build_extraction_lock(output_dir)
|
|
1229
|
+
require_relative '../coordination/pipeline_lock'
|
|
1230
|
+
require_relative '../coordination/lock_heartbeat'
|
|
1231
|
+
require_relative '../watch/daemon'
|
|
1232
|
+
|
|
1233
|
+
Woods::Coordination::PipelineLock.new(
|
|
1234
|
+
lock_dir: output_dir.to_s,
|
|
1235
|
+
name: Woods::Watch::Daemon::LOCK_NAME,
|
|
1236
|
+
stale_timeout: Woods::Watch::Daemon::LOCK_STALE_TIMEOUT
|
|
1237
|
+
)
|
|
1238
|
+
end
|
|
964
1239
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1240
|
+
# Poll for the lock until {PIPELINE_LOCK_WAIT} elapses. Monotonic, so
|
|
1241
|
+
# a clock adjustment mid-wait cannot stretch or shrink the window.
|
|
1242
|
+
#
|
|
1243
|
+
# @param lock [Woods::Coordination::PipelineLock]
|
|
1244
|
+
# @return [Boolean] whether the lock was acquired
|
|
1245
|
+
def acquire_lock_briefly(lock)
|
|
1246
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PIPELINE_LOCK_WAIT
|
|
1247
|
+
acquired = lock.acquire
|
|
1248
|
+
until acquired || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
1249
|
+
sleep 0.1
|
|
1250
|
+
acquired = lock.acquire
|
|
969
1251
|
end
|
|
1252
|
+
acquired
|
|
970
1253
|
end
|
|
971
1254
|
|
|
972
|
-
|
|
1255
|
+
# Build the cooldown-gate error for pipeline_extract/pipeline_embed,
|
|
1256
|
+
# or nil when the operation may proceed.
|
|
1257
|
+
#
|
|
1258
|
+
# `PipelineGuard#allow?` fails closed on state it cannot verify
|
|
1259
|
+
# (corrupt or permission-denied), which reads identically to a
|
|
1260
|
+
# genuine, elapsing cooldown from the boolean alone. Reporting
|
|
1261
|
+
# `:rate_limited, retry_after_seconds: 300` for state that will
|
|
1262
|
+
# never resolve on its own is the inaccurate public metadata this
|
|
1263
|
+
# closes — `PipelineGuard#state_status` distinguishes why, and the
|
|
1264
|
+
# tool error now says so.
|
|
1265
|
+
#
|
|
1266
|
+
# @param guard [Woods::Operator::PipelineGuard, nil]
|
|
1267
|
+
# @param operation [Symbol] :extraction or :embedding
|
|
1268
|
+
# @param tool [String] tool name, for the error payload
|
|
1269
|
+
# @return [MCP::Tool::Response, nil]
|
|
1270
|
+
def cooldown_error(guard, operation, tool)
|
|
1271
|
+
return nil unless guard
|
|
1272
|
+
return nil if guard.allow?(operation)
|
|
1273
|
+
|
|
1274
|
+
case guard.state_status
|
|
1275
|
+
when :corrupt
|
|
1276
|
+
# `pipeline_repair`'s `reset_cooldowns` action deletes state by
|
|
1277
|
+
# key and cannot act on content it cannot parse, so it will not
|
|
1278
|
+
# clear this — the fix is replacing or removing the state file
|
|
1279
|
+
# directly.
|
|
1280
|
+
error_response(
|
|
1281
|
+
'Pipeline cooldown state is corrupt, so the cooldown cannot be verified. ' \
|
|
1282
|
+
'Inspect and replace (or remove) pipeline_guard.json in the operator state directory.',
|
|
1283
|
+
code: :cooldown_state_corrupt, tool: tool
|
|
1284
|
+
)
|
|
1285
|
+
when :permission_denied
|
|
1286
|
+
error_response(
|
|
1287
|
+
'Pipeline cooldown state is unreadable (permission denied), so the cooldown cannot be verified. ' \
|
|
1288
|
+
'Check the operator state directory permissions.',
|
|
1289
|
+
code: :cooldown_state_unreadable, tool: tool
|
|
1290
|
+
)
|
|
1291
|
+
else
|
|
1292
|
+
error_response(
|
|
1293
|
+
"#{operation == :extraction ? 'Extraction' : 'Embedding'} is rate-limited. Try again later.",
|
|
1294
|
+
code: :rate_limited, tool: tool, retry_after_seconds: 300
|
|
1295
|
+
)
|
|
1296
|
+
end
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1299
|
+
def define_pipeline_embed_tool(server, operator, respond, respond_err, op_missing, task_store)
|
|
1300
|
+
cooldown = method(:cooldown_error)
|
|
973
1301
|
server.define_tool(
|
|
974
1302
|
name: 'pipeline_embed',
|
|
975
1303
|
description: 'Trigger embedding generation for extracted units. Checks rate limits before proceeding.',
|
|
@@ -982,49 +1310,239 @@ module Woods
|
|
|
982
1310
|
next op_missing.call('pipeline_embed') unless operator
|
|
983
1311
|
|
|
984
1312
|
guard = operator[:pipeline_guard]
|
|
985
|
-
if
|
|
986
|
-
next
|
|
987
|
-
'Embedding is rate-limited. Try again later.',
|
|
988
|
-
code: :rate_limited,
|
|
989
|
-
tool: 'pipeline_embed',
|
|
990
|
-
retry_after_seconds: 300
|
|
991
|
-
)
|
|
1313
|
+
if (blocked = cooldown.call(guard, :embedding, 'pipeline_embed'))
|
|
1314
|
+
next blocked
|
|
992
1315
|
end
|
|
993
1316
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1317
|
+
build_embed_runner = lambda do |_output_dir|
|
|
1318
|
+
lambda do
|
|
1319
|
+
# Share the rake-task wiring so the MCP path picks up the
|
|
1320
|
+
# provider-tuned TextPreparer + token-aware chunker. Without
|
|
1321
|
+
# this, MCP-triggered embedding still hit Ollama's "input
|
|
1322
|
+
# length exceeds context length" error after the rake path
|
|
1323
|
+
# was fixed in PR #70.
|
|
1324
|
+
indexer = Woods::Tasks.build_embed_indexer
|
|
1325
|
+
incremental ? indexer.index_incremental : indexer.index_all
|
|
1326
|
+
end
|
|
1002
1327
|
end
|
|
1003
1328
|
|
|
1004
|
-
|
|
1329
|
+
next Woods::MCP::Server.send(
|
|
1330
|
+
:start_pipeline_run,
|
|
1331
|
+
kind: :embedding, tool: 'pipeline_embed',
|
|
1332
|
+
task_store: task_store, respond: respond, respond_err: respond_err,
|
|
1333
|
+
runner_builder: build_embed_runner,
|
|
1334
|
+
started: -> { guard&.record!(:embedding) },
|
|
1335
|
+
started_message: 'Embedding pipeline started in background thread'
|
|
1336
|
+
)
|
|
1337
|
+
end
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1340
|
+
# Claim the in-process pipeline slot, take the cross-process writer
|
|
1341
|
+
# lock, and hand the run off to a background thread.
|
|
1342
|
+
#
|
|
1343
|
+
# Owns the entire window between {pipeline_start} and the hand-off.
|
|
1344
|
+
# Before this existed, ANY raise inside that window skipped
|
|
1345
|
+
# {pipeline_finish} for the life of the process, so every later call
|
|
1346
|
+
# answered `already_running` and only a restart cleared it (MCP-3).
|
|
1347
|
+
# `PipelineLock#acquire` rescues only `Errno::EEXIST` and opens/creates
|
|
1348
|
+
# files, so an index directory this process cannot write — the
|
|
1349
|
+
# documented Docker deployment mounts the index into a host-side
|
|
1350
|
+
# server — raises `SystemCallError` out of {acquire_lock_briefly}
|
|
1351
|
+
# rather than returning false. That escaping error also reached
|
|
1352
|
+
# {ToolContract} as a nested SystemCallError and came back as
|
|
1353
|
+
# `corrupt_artifact` ("An Index artifact is unavailable or malformed"),
|
|
1354
|
+
# a misdiagnosis of a permissions failure. Both are closed here, with
|
|
1355
|
+
# the same handoff-flag shape {run_pipeline_in_background} uses one
|
|
1356
|
+
# layer down (L6).
|
|
1357
|
+
#
|
|
1358
|
+
# Cross-PROCESS serialization (#170): {pipeline_start} only guards this
|
|
1359
|
+
# process; the rake writers and the watch daemon serialize on the
|
|
1360
|
+
# on-disk PipelineLock, and an unlocked MCP run was free to rewrite the
|
|
1361
|
+
# index under any of them with the loser's work silently discarded. The
|
|
1362
|
+
# wait is short and the failure explicit — an MCP tool must not sit on
|
|
1363
|
+
# a lock queue for minutes the way `woods:extract` does.
|
|
1364
|
+
#
|
|
1365
|
+
# A nil/duck-typed configuration (no output_dir) yields no lock: there
|
|
1366
|
+
# is no known lock domain, and the run fails in the background exactly
|
|
1367
|
+
# as it always has on an unconfigured host.
|
|
1368
|
+
#
|
|
1369
|
+
# @param kind [Symbol] :extraction or :embedding
|
|
1370
|
+
# @param tool [String] tool name, for the error payloads
|
|
1371
|
+
# @param runner_builder [Proc] called with the resolved output_dir;
|
|
1372
|
+
# returns the runner lambda {run_pipeline_in_background} executes
|
|
1373
|
+
# @return [Hash, MCP::Tool::Response]
|
|
1374
|
+
def start_pipeline_run(kind:, tool:, task_store:, respond:, respond_err:, runner_builder:, started:,
|
|
1375
|
+
started_message:)
|
|
1376
|
+
# Claim the slot BEFORE recording to the guard. Otherwise a refused
|
|
1377
|
+
# "already running" request still resets the cooldown clock and
|
|
1378
|
+
# blocks the next legitimate attempt for the full window.
|
|
1379
|
+
unless pipeline_start(kind)
|
|
1380
|
+
return respond_err.call(
|
|
1381
|
+
"#{PIPELINE_LABELS.fetch(kind)} pipeline is already running. Wait for it to complete.",
|
|
1382
|
+
code: :already_running,
|
|
1383
|
+
tool: tool
|
|
1384
|
+
)
|
|
1385
|
+
end
|
|
1386
|
+
|
|
1387
|
+
handed_off = false
|
|
1388
|
+
held_lock = nil
|
|
1389
|
+
begin
|
|
1390
|
+
config = Woods.configuration
|
|
1391
|
+
output_dir = config.output_dir if config.respond_to?(:output_dir)
|
|
1392
|
+
lock = output_dir && build_extraction_lock(output_dir)
|
|
1393
|
+
if lock
|
|
1394
|
+
unless acquire_lock_briefly(lock)
|
|
1395
|
+
return respond_err.call(
|
|
1396
|
+
'Another writer holds the extraction lock (a rake task or the watch daemon ' \
|
|
1397
|
+
'is writing this index). Try again shortly.',
|
|
1398
|
+
code: :locked,
|
|
1399
|
+
tool: tool
|
|
1400
|
+
)
|
|
1401
|
+
end
|
|
1402
|
+
|
|
1403
|
+
held_lock = lock
|
|
1404
|
+
end
|
|
1405
|
+
|
|
1406
|
+
# Built before the flag flips: a runner_builder that raises must
|
|
1407
|
+
# still release the lock and the slot on the way out.
|
|
1408
|
+
runner = runner_builder.call(output_dir)
|
|
1409
|
+
|
|
1410
|
+
# From here the background hand-off owns both the on-disk lock and
|
|
1411
|
+
# the in-process slot: run_pipeline_in_background releases them on
|
|
1412
|
+
# every one of its own paths.
|
|
1413
|
+
handed_off = true
|
|
1414
|
+
run_pipeline_in_background(
|
|
1415
|
+
kind: kind, tool: tool, lock: lock, task_store: task_store,
|
|
1416
|
+
respond: respond, respond_err: respond_err, runner: runner,
|
|
1417
|
+
started: started, started_message: started_message
|
|
1418
|
+
)
|
|
1419
|
+
rescue SystemCallError => e
|
|
1420
|
+
respond_err.call(
|
|
1421
|
+
'The index directory\'s writer lock could not be taken — this is a filesystem ' \
|
|
1422
|
+
"permissions problem, not a corrupt index (#{e.class}: #{e.message}). Make the index " \
|
|
1423
|
+
'directory writable by this process, then invoke the tool again.',
|
|
1424
|
+
code: :lock_unwritable,
|
|
1425
|
+
tool: tool,
|
|
1426
|
+
exception: e.class.name
|
|
1427
|
+
)
|
|
1428
|
+
ensure
|
|
1429
|
+
unless handed_off
|
|
1430
|
+
held_lock&.release
|
|
1431
|
+
pipeline_finish(kind)
|
|
1432
|
+
end
|
|
1433
|
+
end
|
|
1434
|
+
end
|
|
1435
|
+
|
|
1436
|
+
# Run a pipeline on a background thread and answer the caller.
|
|
1437
|
+
#
|
|
1438
|
+
# Both pipeline tools reached this point with the same shape: a lock
|
|
1439
|
+
# they may or may not hold, a runner lambda, and the need to answer
|
|
1440
|
+
# immediately because a full run takes minutes. What differs now is
|
|
1441
|
+
# *how* they answer.
|
|
1442
|
+
#
|
|
1443
|
+
# When the client declared the Tasks extension, the answer is a durable
|
|
1444
|
+
# `CreateTaskResult`. That is the whole point of the extension here: the
|
|
1445
|
+
# handle outlives this process, so a client that disconnects mid-run can
|
|
1446
|
+
# reconnect and still learn whether extraction succeeded — and if the
|
|
1447
|
+
# process dies, {Tasks::Store} resolves the orphaned record to `failed`
|
|
1448
|
+
# instead of leaving an agent polling a run that no longer exists.
|
|
1449
|
+
#
|
|
1450
|
+
# When it did not, the answer is exactly what it always was. The spec is
|
|
1451
|
+
# explicit that a task must never go to a client that did not opt in:
|
|
1452
|
+
# such a client would read the handle as the final result and report a
|
|
1453
|
+
# completed run that had not started.
|
|
1454
|
+
#
|
|
1455
|
+
# @param kind [Symbol] :extraction or :embedding, for the in-process lock
|
|
1456
|
+
# @param tool [String] tool name, for the task record and error text
|
|
1457
|
+
# @param lock [Woods::Coordination::PipelineLock, nil]
|
|
1458
|
+
# @param task_store [Tasks::Store, nil] nil disables the task path
|
|
1459
|
+
# @param respond [Method] the text-response builder
|
|
1460
|
+
# @param runner [Proc] the actual work
|
|
1461
|
+
# @param started_message [String] legacy fire-and-forget message
|
|
1462
|
+
# @return [Hash, MCP::Tool::Response]
|
|
1463
|
+
def run_pipeline_in_background(kind:, tool:, lock:, task_store:, respond:, respond_err:, runner:, started:,
|
|
1464
|
+
started_message:)
|
|
1465
|
+
# Ownership of the on-disk lock transfers to the background thread
|
|
1466
|
+
# the moment Thread.new succeeds — its ensure releases it when the
|
|
1467
|
+
# run finishes. Before that point (guard failures, started hook),
|
|
1468
|
+
# THIS method owns the lock and must release it on the way out
|
|
1469
|
+
# (L6): the rescue below covers only SystemCallError/IOError, so a
|
|
1470
|
+
# guard bug raising anything else used to leak the on-disk lock
|
|
1471
|
+
# and block every later writer until the stale window expired.
|
|
1472
|
+
handoff = false
|
|
1473
|
+
begin
|
|
1474
|
+
task = create_pipeline_task(task_store, tool)
|
|
1475
|
+
started.call
|
|
1005
1476
|
|
|
1006
1477
|
Thread.new do
|
|
1007
|
-
#
|
|
1008
|
-
#
|
|
1009
|
-
#
|
|
1010
|
-
#
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1478
|
+
# Heartbeat, like the rake writers: a full run on a large host can
|
|
1479
|
+
# outlive the lock's stale window, and an untouched lock would be
|
|
1480
|
+
# retired by the next contender mid-run — recreating the two-writer
|
|
1481
|
+
# clobber.
|
|
1482
|
+
if lock
|
|
1483
|
+
Woods::Coordination::LockHeartbeat.run(lock) { runner.call }
|
|
1484
|
+
else
|
|
1485
|
+
runner.call
|
|
1486
|
+
end
|
|
1487
|
+
task_store&.complete!(task.id, result: pipeline_task_result(tool)) if task
|
|
1488
|
+
rescue StandardError, ScriptError => e
|
|
1489
|
+
# ScriptError (SyntaxError, LoadError) is not a StandardError, and
|
|
1490
|
+
# +runner.call+ can lazily +require_relative+ the extractor — a
|
|
1491
|
+
# half-typed file must degrade this background thread, not kill it
|
|
1492
|
+
# silently while the task record sits at "working" until pid-death
|
|
1493
|
+
# (see the "rescue ScriptError anywhere a reload can happen" rule).
|
|
1015
1494
|
logger = defined?(Rails) ? Rails.logger : Logger.new($stderr)
|
|
1016
|
-
logger.error("[Woods] Pipeline
|
|
1495
|
+
logger.error("[Woods] Pipeline #{kind} failed: #{e.message}")
|
|
1496
|
+
# Recording the failure is the half that was missing: previously the
|
|
1497
|
+
# error reached a log the agent cannot read, and the tool had
|
|
1498
|
+
# already reported success.
|
|
1499
|
+
task_store&.fail!(task.id, message: "#{e.class}: #{e.message}") if task
|
|
1017
1500
|
ensure
|
|
1018
|
-
|
|
1501
|
+
lock&.release
|
|
1502
|
+
Woods::MCP::Server.send(:pipeline_finish, kind)
|
|
1019
1503
|
end
|
|
1504
|
+
handoff = true
|
|
1020
1505
|
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1506
|
+
return Tasks::Extension.create_task_result(task) if task
|
|
1507
|
+
|
|
1508
|
+
respond.call(JSON.pretty_generate({ status: 'started', message: started_message }))
|
|
1509
|
+
rescue SystemCallError, IOError => e
|
|
1510
|
+
respond_err.call(
|
|
1511
|
+
'The task could not be durably recorded, so the pipeline was not started.',
|
|
1512
|
+
code: :task_store_unavailable,
|
|
1513
|
+
tool: tool,
|
|
1514
|
+
exception: e.class.name
|
|
1515
|
+
)
|
|
1516
|
+
ensure
|
|
1517
|
+
unless handoff
|
|
1518
|
+
lock&.release
|
|
1519
|
+
Woods::MCP::Server.send(:pipeline_finish, kind)
|
|
1520
|
+
end
|
|
1025
1521
|
end
|
|
1026
1522
|
end
|
|
1027
1523
|
|
|
1524
|
+
# Mint a task record, or return nil to take the legacy path.
|
|
1525
|
+
#
|
|
1526
|
+
# Nil only when the client did not opt in. Once a client opts in, task
|
|
1527
|
+
# durability is part of the response contract; a write failure propagates
|
|
1528
|
+
# to {run_pipeline_in_background}, which fails closed before work starts.
|
|
1529
|
+
#
|
|
1530
|
+
# @return [Tasks::Store::Task, nil]
|
|
1531
|
+
def create_pipeline_task(task_store, tool)
|
|
1532
|
+
return nil unless task_store && Tasks::RequestCapture.tasks_requested?
|
|
1533
|
+
|
|
1534
|
+
task_store.create!(tool: tool)
|
|
1535
|
+
end
|
|
1536
|
+
|
|
1537
|
+
# What `tasks/get` hands back on success — shaped like the synchronous
|
|
1538
|
+
# tool result the caller would have received had it waited.
|
|
1539
|
+
def pipeline_task_result(tool)
|
|
1540
|
+
{
|
|
1541
|
+
'content' => [{ 'type' => 'text', 'text' => "#{tool} completed successfully." }],
|
|
1542
|
+
'isError' => false
|
|
1543
|
+
}
|
|
1544
|
+
end
|
|
1545
|
+
|
|
1028
1546
|
# Acquire a pipeline-kind lock atomically. Returns false when
|
|
1029
1547
|
# another thread is already running that kind of pipeline (so the
|
|
1030
1548
|
# caller can refuse the new request instead of racing the running
|
|
@@ -1124,8 +1642,21 @@ module Woods
|
|
|
1124
1642
|
when 'clear_locks'
|
|
1125
1643
|
lock = operator[:pipeline_lock]
|
|
1126
1644
|
if lock
|
|
1127
|
-
lock.
|
|
1128
|
-
|
|
1645
|
+
outcome = lock.retire_stale
|
|
1646
|
+
case outcome
|
|
1647
|
+
when :cleared
|
|
1648
|
+
respond.call(JSON.pretty_generate({ repaired: true, action: 'clear_locks', outcome: 'cleared' }))
|
|
1649
|
+
when :missing
|
|
1650
|
+
respond_err.call(
|
|
1651
|
+
'No pipeline lock exists; nothing was repaired.',
|
|
1652
|
+
code: :lock_missing, tool: 'pipeline_repair', action: action, repaired: false
|
|
1653
|
+
)
|
|
1654
|
+
when :not_stale
|
|
1655
|
+
respond_err.call(
|
|
1656
|
+
'The pipeline lock is active and was not cleared.',
|
|
1657
|
+
code: :lock_active, tool: 'pipeline_repair', action: action, repaired: false
|
|
1658
|
+
)
|
|
1659
|
+
end
|
|
1129
1660
|
else
|
|
1130
1661
|
respond_err.call(
|
|
1131
1662
|
'Pipeline lock is not configured.',
|
|
@@ -1135,7 +1666,22 @@ module Woods
|
|
|
1135
1666
|
)
|
|
1136
1667
|
end
|
|
1137
1668
|
when 'reset_cooldowns'
|
|
1138
|
-
|
|
1669
|
+
guard = operator[:pipeline_guard]
|
|
1670
|
+
if guard.nil?
|
|
1671
|
+
respond_err.call(
|
|
1672
|
+
'Pipeline guard is not configured.',
|
|
1673
|
+
code: :not_configured,
|
|
1674
|
+
config_key: 'operator.pipeline_guard',
|
|
1675
|
+
tool: 'pipeline_repair'
|
|
1676
|
+
)
|
|
1677
|
+
elsif guard.reset!(:all)
|
|
1678
|
+
respond.call(JSON.pretty_generate({ repaired: true, action: action, outcome: 'reset' }))
|
|
1679
|
+
else
|
|
1680
|
+
respond_err.call(
|
|
1681
|
+
'No pipeline cooldown state exists; nothing was repaired.',
|
|
1682
|
+
code: :cooldown_state_missing, tool: 'pipeline_repair', action: action, repaired: false
|
|
1683
|
+
)
|
|
1684
|
+
end
|
|
1139
1685
|
else
|
|
1140
1686
|
respond_err.call(
|
|
1141
1687
|
"Unknown repair action: #{action}",
|
|
@@ -1359,7 +1905,7 @@ module Woods
|
|
|
1359
1905
|
'notion_api_token is not configured. Set it in Woods.configure or via the NOTION_API_TOKEN env var.',
|
|
1360
1906
|
code: :not_configured,
|
|
1361
1907
|
config_key: 'notion_api_token',
|
|
1362
|
-
doc_link: 'docs/
|
|
1908
|
+
doc_link: 'docs/NOTION_INTEGRATION.md',
|
|
1363
1909
|
tool: 'notion_sync'
|
|
1364
1910
|
)
|
|
1365
1911
|
end
|
|
@@ -1369,7 +1915,7 @@ module Woods
|
|
|
1369
1915
|
'notion_database_ids is not configured. Set it in Woods.configure.',
|
|
1370
1916
|
code: :not_configured,
|
|
1371
1917
|
config_key: 'notion_database_ids',
|
|
1372
|
-
doc_link: 'docs/
|
|
1918
|
+
doc_link: 'docs/NOTION_INTEGRATION.md',
|
|
1373
1919
|
tool: 'notion_sync'
|
|
1374
1920
|
)
|
|
1375
1921
|
end
|
|
@@ -1459,6 +2005,20 @@ module Woods
|
|
|
1459
2005
|
# status claiming +embedding_model: "text-embedding-3-small"+ next to
|
|
1460
2006
|
# +embedding_provider: "ollama"+ and reasonably distrust every field.
|
|
1461
2007
|
def build_status(reader:, retriever:, index_dir:, bootstrap_state: nil)
|
|
2008
|
+
# Pin the generation across the whole payload. Without this the
|
|
2009
|
+
# manifest can be read at generation N and `generation_fields` then
|
|
2010
|
+
# report N+1 — a status report that describes counts from one index
|
|
2011
|
+
# while announcing the number of another, which is precisely the
|
|
2012
|
+
# confusion this tool exists to resolve.
|
|
2013
|
+
return build_status_payload(reader, retriever, index_dir, bootstrap_state) unless
|
|
2014
|
+
reader.respond_to?(:with_pinned_generation)
|
|
2015
|
+
|
|
2016
|
+
reader.with_pinned_generation do
|
|
2017
|
+
build_status_payload(reader, retriever, index_dir, bootstrap_state)
|
|
2018
|
+
end
|
|
2019
|
+
end
|
|
2020
|
+
|
|
2021
|
+
def build_status_payload(reader, retriever, index_dir, bootstrap_state)
|
|
1462
2022
|
manifest = safe_manifest(reader)
|
|
1463
2023
|
extracted_at = manifest && manifest['extracted_at']
|
|
1464
2024
|
staleness = staleness_seconds(extracted_at)
|
|
@@ -1476,7 +2036,8 @@ module Woods
|
|
|
1476
2036
|
index_dir: index_dir.to_s,
|
|
1477
2037
|
update: Woods::UpdateCheck.status_hash
|
|
1478
2038
|
},
|
|
1479
|
-
index: index_section(manifest, extracted_at, staleness, index_dir),
|
|
2039
|
+
index: index_section(manifest, extracted_at, staleness, index_dir, reader),
|
|
2040
|
+
watch: watch_section(index_dir),
|
|
1480
2041
|
retriever: {
|
|
1481
2042
|
configured: !retriever.nil?,
|
|
1482
2043
|
class: retriever&.class&.name
|
|
@@ -1505,7 +2066,7 @@ module Woods
|
|
|
1505
2066
|
# diff directly. This is an observability signal, not a hard gate —
|
|
1506
2067
|
# hard-refusing responses would be much more disruptive than a loudly-
|
|
1507
2068
|
# visible staleness flag that agents can branch on.
|
|
1508
|
-
def index_section(manifest, extracted_at, staleness, index_dir)
|
|
2069
|
+
def index_section(manifest, extracted_at, staleness, index_dir, reader = nil)
|
|
1509
2070
|
base = {
|
|
1510
2071
|
extracted_at: extracted_at,
|
|
1511
2072
|
staleness_seconds: staleness,
|
|
@@ -1519,6 +2080,9 @@ module Woods
|
|
|
1519
2080
|
schema_sha: manifest && manifest['schema_sha']
|
|
1520
2081
|
}
|
|
1521
2082
|
|
|
2083
|
+
base.merge!(generation_fields(index_dir, reader))
|
|
2084
|
+
base.merge!(working_tree_fields(index_dir))
|
|
2085
|
+
|
|
1522
2086
|
manifest_sha = manifest && manifest['git_sha']
|
|
1523
2087
|
head_sha = manifest_sha ? resolve_head_sha(index_dir) : nil
|
|
1524
2088
|
return base unless head_sha
|
|
@@ -1528,20 +2092,156 @@ module Woods
|
|
|
1528
2092
|
base
|
|
1529
2093
|
end
|
|
1530
2094
|
|
|
2095
|
+
# The generation the index is published at.
|
|
2096
|
+
#
|
|
2097
|
+
# Every extraction mode that writes the *unit* index bumps this as its
|
|
2098
|
+
# last write, so it answers "has the index moved?" without comparing
|
|
2099
|
+
# timestamps — which `staleness_seconds` can't, since it measures
|
|
2100
|
+
# wall-clock age rather than whether anything changed.
|
|
2101
|
+
#
|
|
2102
|
+
# One carve-out: `woods:extract_framework` writes only `rails_source/`
|
|
2103
|
+
# and does not bump, so a framework re-extraction leaves this number
|
|
2104
|
+
# where it was. That is deliberate — framework sources are pinned by the
|
|
2105
|
+
# `Gemfile.lock`, reported separately above, and treating them as an
|
|
2106
|
+
# index generation would invalidate every reader's cache for data that
|
|
2107
|
+
# changes when dependencies do, not when the app does.
|
|
2108
|
+
#
|
|
2109
|
+
# @return [Hash]
|
|
2110
|
+
def generation_fields(index_dir, reader = nil)
|
|
2111
|
+
return {} unless index_dir
|
|
2112
|
+
|
|
2113
|
+
marker = Woods::Generation.new(output_dir: index_dir).current
|
|
2114
|
+
return { generation: nil } if marker.number.zero?
|
|
2115
|
+
|
|
2116
|
+
fields = { generation: marker.number,
|
|
2117
|
+
generation_updated_at: marker.updated_at,
|
|
2118
|
+
generation_reason: marker.reason }
|
|
2119
|
+
fields.merge(served_generation_fields(marker, reader))
|
|
2120
|
+
rescue StandardError
|
|
2121
|
+
{}
|
|
2122
|
+
end
|
|
2123
|
+
|
|
2124
|
+
# What the *reader* is actually serving, which is not always what is
|
|
2125
|
+
# published.
|
|
2126
|
+
#
|
|
2127
|
+
# `build_status` pins the reader so the manifest and counts above come
|
|
2128
|
+
# from one generation, but this method reads `generation.json` from
|
|
2129
|
+
# disk — so a publish landing mid-call would otherwise report a
|
|
2130
|
+
# generation number beside counts from the previous one, the exact
|
|
2131
|
+
# mismatch the pin is there to remove. When they differ, say so instead
|
|
2132
|
+
# of quietly picking one.
|
|
2133
|
+
def served_generation_fields(marker, reader)
|
|
2134
|
+
return {} unless reader.respond_to?(:loaded_generation)
|
|
2135
|
+
|
|
2136
|
+
served = reader.loaded_generation
|
|
2137
|
+
return {} if served.nil? || served == marker.number
|
|
2138
|
+
|
|
2139
|
+
{ served_generation: served, generation_lag: marker.number - served }
|
|
2140
|
+
rescue StandardError
|
|
2141
|
+
{}
|
|
2142
|
+
end
|
|
2143
|
+
|
|
2144
|
+
# Whether the working tree has uncommitted changes, and a fingerprint
|
|
2145
|
+
# of them.
|
|
2146
|
+
#
|
|
2147
|
+
# `git_sha_matches_head` only sees *committed* HEAD, so an agent
|
|
2148
|
+
# working through forty uncommitted edits could be told the index
|
|
2149
|
+
# matches HEAD while every answer described the tree before those
|
|
2150
|
+
# edits. `working_tree_dirty` is the fix for that.
|
|
2151
|
+
#
|
|
2152
|
+
# The fingerprint is a digest of `git status --porcelain` *as of this
|
|
2153
|
+
# call*. Nothing records the digest the index was built at, so it does
|
|
2154
|
+
# not answer "is this the same dirty state the index describes" — it
|
|
2155
|
+
# gives a caller a stable identity for the current dirty state, so two
|
|
2156
|
+
# of its own calls can be compared to detect the tree moving underneath
|
|
2157
|
+
# it. Pair it with `generation` to tell "tree changed, index followed"
|
|
2158
|
+
# from "tree changed, index has not caught up".
|
|
2159
|
+
#
|
|
2160
|
+
# @return [Hash]
|
|
2161
|
+
def working_tree_fields(index_dir)
|
|
2162
|
+
porcelain = resolve_working_tree_status(index_dir)
|
|
2163
|
+
return {} if porcelain.nil?
|
|
2164
|
+
|
|
2165
|
+
{ working_tree_dirty: !porcelain.empty?,
|
|
2166
|
+
working_tree_fingerprint: Digest::SHA256.hexdigest(porcelain)[0, 16] }
|
|
2167
|
+
end
|
|
2168
|
+
|
|
2169
|
+
# `git status --porcelain` for the repo containing +index_dir+, or nil
|
|
2170
|
+
# when that can't be answered.
|
|
2171
|
+
#
|
|
2172
|
+
# capture3, not capture2e: stderr still must not reach the stdio
|
|
2173
|
+
# transport, but folding it into stdout makes any warning git emits on a
|
|
2174
|
+
# successful run — a stale index.lock notice, a detached-HEAD advisory,
|
|
2175
|
+
# `core.fsmonitor` chatter — part of the "porcelain" output. A clean tree
|
|
2176
|
+
# then reports dirty, and the fingerprint changes with the warning rather
|
|
2177
|
+
# than with the code.
|
|
2178
|
+
def resolve_working_tree_status(index_dir)
|
|
2179
|
+
return nil unless index_dir
|
|
2180
|
+
|
|
2181
|
+
dir = index_dir.to_s
|
|
2182
|
+
return nil unless File.directory?(dir)
|
|
2183
|
+
|
|
2184
|
+
output, _stderr, status = Open3.capture3('git', '-C', dir, 'status', '--porcelain')
|
|
2185
|
+
status.success? ? output : nil
|
|
2186
|
+
rescue StandardError
|
|
2187
|
+
nil
|
|
2188
|
+
end
|
|
2189
|
+
|
|
2190
|
+
# The watch daemon's state, so an agent can branch on whether anything
|
|
2191
|
+
# is keeping this index current.
|
|
2192
|
+
#
|
|
2193
|
+
# Three states matter and they are not interchangeable: `running`
|
|
2194
|
+
# (current, or current within a debounce window), `degraded` (alive but
|
|
2195
|
+
# unable to update — the reason says why, and the index is frozen at a
|
|
2196
|
+
# known generation), and `stopped`/`absent` (nothing is maintaining
|
|
2197
|
+
# this index; fall back to whatever the last explicit run left).
|
|
2198
|
+
#
|
|
2199
|
+
# @return [Hash]
|
|
2200
|
+
def watch_section(index_dir)
|
|
2201
|
+
return { state: 'absent' } unless index_dir
|
|
2202
|
+
|
|
2203
|
+
path = File.join(index_dir.to_s, Woods::Watch::Status::FILENAME)
|
|
2204
|
+
return { state: 'absent' } unless File.exist?(path)
|
|
2205
|
+
|
|
2206
|
+
# AtomicFile.read: the daemon's reasons contain em dashes, and a
|
|
2207
|
+
# US-ASCII default external encoding turns a plain File.read of them
|
|
2208
|
+
# into an Encoding::InvalidByteSequenceError — raising out of
|
|
2209
|
+
# woods_status entirely rather than degrading it.
|
|
2210
|
+
record = JSON.parse(Woods::AtomicFile.read(path))
|
|
2211
|
+
# `state` is whatever the daemon last wrote, and a `kill -9`'d daemon
|
|
2212
|
+
# leaves `running` behind forever. `alive?` adds the two checks that
|
|
2213
|
+
# catch that — the pid still exists and the record is recent — so the
|
|
2214
|
+
# payload can distinguish "maintaining this index" from "claimed to be,
|
|
2215
|
+
# once". Reported as a separate field rather than by overwriting
|
|
2216
|
+
# `state`, because the recorded state and the liveness verdict answer
|
|
2217
|
+
# different questions and an operator wants both.
|
|
2218
|
+
status = Woods::Watch::Status.new(output_dir: index_dir)
|
|
2219
|
+
{ state: record['state'], reason: record['reason'], generation: record['generation'],
|
|
2220
|
+
pid: record['pid'], updated_at: record['updated_at'],
|
|
2221
|
+
alive: status.alive?, stale_after_seconds: Woods::Watch::Status::STALE_AFTER,
|
|
2222
|
+
last_action: record['last_action'], last_duration_ms: record['last_duration_ms'] }
|
|
2223
|
+
rescue StandardError
|
|
2224
|
+
{ state: 'absent' }
|
|
2225
|
+
end
|
|
2226
|
+
|
|
1531
2227
|
# Resolve the current HEAD SHA for the git repo containing +index_dir+.
|
|
1532
2228
|
# Returns nil when git is unavailable or +index_dir+ is not in a repo —
|
|
1533
2229
|
# callers treat nil as "can't compare" rather than "mismatch".
|
|
1534
2230
|
#
|
|
1535
|
-
#
|
|
1536
|
-
#
|
|
1537
|
-
#
|
|
2231
|
+
# capture3 keeps git's stderr out of the MCP stdio transport — clients
|
|
2232
|
+
# that parse stderr for protocol framing can't tolerate stray lines —
|
|
2233
|
+
# *and* out of the SHA. capture2e folded them together, so a warning on
|
|
2234
|
+
# an otherwise successful `rev-parse` (a stale `index.lock` notice, a
|
|
2235
|
+
# `core.fsmonitor` complaint) was concatenated into the value this
|
|
2236
|
+
# method returns and compared against the manifest as if it were a SHA.
|
|
2237
|
+
# Same hazard as {#resolve_working_tree_status}, one probe over.
|
|
1538
2238
|
def resolve_head_sha(index_dir)
|
|
1539
2239
|
return nil unless index_dir
|
|
1540
2240
|
|
|
1541
2241
|
dir = index_dir.to_s
|
|
1542
2242
|
return nil unless File.directory?(dir)
|
|
1543
2243
|
|
|
1544
|
-
output, status = Open3.
|
|
2244
|
+
output, _stderr, status = Open3.capture3('git', '-C', dir, 'rev-parse', 'HEAD')
|
|
1545
2245
|
status.success? ? output.strip : nil
|
|
1546
2246
|
rescue Errno::ENOENT, Errno::EACCES
|
|
1547
2247
|
# git not installed or not executable on this host — equivalent to
|
|
@@ -1621,28 +2321,74 @@ module Woods
|
|
|
1621
2321
|
def register_resource_handler(server, reader)
|
|
1622
2322
|
server.resources_read_handler do |params|
|
|
1623
2323
|
uri = params[:uri]
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
2324
|
+
kind, target = parse_resource_uri(uri)
|
|
2325
|
+
raise ::MCP::Server::ResourceNotFoundError.new(uri, params) unless kind
|
|
2326
|
+
|
|
2327
|
+
payload = resource_payload(reader, kind, target)
|
|
2328
|
+
raise ::MCP::Server::ResourceNotFoundError.new(uri, params) if payload.nil?
|
|
2329
|
+
|
|
2330
|
+
[{ uri: uri, mimeType: 'application/json', text: JSON.pretty_generate(payload) }]
|
|
2331
|
+
rescue ::MCP::Server::ResourceNotFoundError
|
|
2332
|
+
raise
|
|
2333
|
+
rescue JSON::ParserError, SystemCallError, IOError, TypeError => e
|
|
2334
|
+
raise corrupt_resource_error(uri, params, e)
|
|
2335
|
+
end
|
|
2336
|
+
end
|
|
2337
|
+
|
|
2338
|
+
def parse_resource_uri(uri)
|
|
2339
|
+
return [:manifest, nil] if uri == 'codebase://manifest'
|
|
2340
|
+
return [:graph, nil] if uri == 'codebase://graph'
|
|
2341
|
+
return unless uri.is_a?(String)
|
|
2342
|
+
|
|
2343
|
+
parsed = URI.parse(uri)
|
|
2344
|
+
return unless parsed.scheme == 'codebase'
|
|
2345
|
+
return unless %w[unit type].include?(parsed.host)
|
|
2346
|
+
return if parsed.userinfo || parsed.port || parsed.query || parsed.fragment || parsed.opaque
|
|
2347
|
+
|
|
2348
|
+
raw_target = parsed.path.to_s.delete_prefix('/')
|
|
2349
|
+
return if raw_target.empty? || raw_target.include?('/')
|
|
2350
|
+
|
|
2351
|
+
target = URI::DEFAULT_PARSER.unescape(raw_target).force_encoding(Encoding::UTF_8)
|
|
2352
|
+
return unless target.valid_encoding?
|
|
2353
|
+
return if target.match?(%r{[%\\/\x00-\x1f\x7f]})
|
|
2354
|
+
return if %w[. ..].include?(target)
|
|
2355
|
+
|
|
2356
|
+
[parsed.host.to_sym, target]
|
|
2357
|
+
rescue URI::InvalidURIError
|
|
2358
|
+
nil
|
|
2359
|
+
end
|
|
2360
|
+
|
|
2361
|
+
def resource_payload(reader, kind, target)
|
|
2362
|
+
case kind
|
|
2363
|
+
when :manifest
|
|
2364
|
+
reader.manifest.tap { |value| raise TypeError unless value.is_a?(Hash) }
|
|
2365
|
+
when :graph
|
|
2366
|
+
reader.raw_graph_data.tap do |value|
|
|
2367
|
+
raise TypeError unless value.is_a?(Hash) && value['nodes'].is_a?(Hash) && value['edges'].is_a?(Hash)
|
|
2368
|
+
end
|
|
2369
|
+
when :unit
|
|
2370
|
+
reader.find_unit(target).tap do |value|
|
|
2371
|
+
raise TypeError if value && (!value.is_a?(Hash) || value['identifier'] != target)
|
|
2372
|
+
end
|
|
2373
|
+
when :type
|
|
2374
|
+
return nil unless IndexReader::TYPE_TO_DIR.key?(target)
|
|
2375
|
+
|
|
2376
|
+
reader.list_units(type: target).tap do |value|
|
|
2377
|
+
raise TypeError unless value.is_a?(Array) && value.all?(Hash)
|
|
1643
2378
|
end
|
|
1644
2379
|
end
|
|
1645
2380
|
end
|
|
2381
|
+
|
|
2382
|
+
def corrupt_resource_error(uri, params, original_error)
|
|
2383
|
+
::MCP::Server::RequestHandlerError.new(
|
|
2384
|
+
'Resource artifact is unavailable or malformed.',
|
|
2385
|
+
params,
|
|
2386
|
+
error_type: :internal_error,
|
|
2387
|
+
original_error: original_error,
|
|
2388
|
+
error_code: ::JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
|
2389
|
+
error_data: { uri: uri, error_code: 'corrupt_artifact' }
|
|
2390
|
+
)
|
|
2391
|
+
end
|
|
1646
2392
|
end
|
|
1647
2393
|
end
|
|
1648
2394
|
end
|