woods 1.6.4 → 2.0.0.beta1
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 +1879 -37
- data/CONTRIBUTING.md +195 -137
- data/README.md +162 -520
- 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 +620 -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 +415 -0
- data/docs/INTERNALS.md +415 -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 +197 -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 +39 -3
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +78 -24
- 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 +40 -1
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +18 -13
- 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/credential_index.rb +5 -53
- data/lib/woods/console/credential_scanner.rb +15 -16
- data/lib/woods/console/dispatch_pipeline.rb +46 -34
- data/lib/woods/console/embedded_executor.rb +806 -257
- 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 +24 -6
- data/lib/woods/console/rack_middleware.rb +62 -63
- data/lib/woods/console/redactor.rb +10 -24
- data/lib/woods/console/safe_context.rb +45 -45
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +136 -267
- data/lib/woods/console/sql_noise_stripper.rb +20 -51
- data/lib/woods/console/sql_table_scanner.rb +39 -90
- data/lib/woods/console/sql_validator.rb +455 -85
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +462 -88
- data/lib/woods/console/tools/tier1.rb +0 -3
- data/lib/woods/console/tools/tier4.rb +17 -7
- 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 +2503 -192
- 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 +63 -21
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +138 -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 +408 -34
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +92 -22
- 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 +108 -23
- 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 +907 -154
- 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/mcp/version_aware_tool_dispatch.rb +3 -9
- 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 +223 -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 +70 -38
- 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 +25 -6
- 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.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
- data/lib/woods/session_tracer/session_flow_document.rb +52 -6
- 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 +53 -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 +102 -30
- data/lib/woods/console/adapter_family.rb +0 -39
- 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/console/credential_scanner_registry.rb +0 -36
- data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
- data/lib/woods/console/sql_output_policy.rb +0 -535
- data/lib/woods/console/sqlite_read_guard.rb +0 -46
- 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/mcp/http_transport_options.rb +0 -15
- data/lib/woods/mcp/origin_policy.rb +0 -113
- 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,27 +122,49 @@ 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
|
|
|
96
|
-
server =
|
|
129
|
+
server = ::MCP::Server.new(
|
|
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
|
)
|
|
137
|
+
# Rewrite "Tool not found" into version-aware update guidance for agents
|
|
138
|
+
# running against an older gem than the skill they're following assumes.
|
|
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)
|
|
102
151
|
|
|
103
152
|
define_lookup_tool(server, reader, respond, respond_err, renderer)
|
|
104
153
|
define_search_tool(server, reader, respond, respond_err, renderer)
|
|
105
154
|
define_traversal_tool(server, reader, respond, renderer,
|
|
106
155
|
name: 'dependencies',
|
|
107
|
-
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.",
|
|
108
160
|
reader_method: :traverse_dependencies,
|
|
109
161
|
render_key: :dependencies)
|
|
110
162
|
define_traversal_tool(server, reader, respond, renderer,
|
|
111
163
|
name: 'dependents',
|
|
112
|
-
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.",
|
|
113
168
|
reader_method: :traverse_dependents,
|
|
114
169
|
render_key: :dependents)
|
|
115
170
|
define_structure_tool(server, reader, respond, renderer)
|
|
@@ -118,9 +173,9 @@ module Woods
|
|
|
118
173
|
define_pagerank_tool(server, reader, respond, renderer)
|
|
119
174
|
define_framework_tool(server, reader, respond, renderer)
|
|
120
175
|
define_recent_changes_tool(server, reader, respond, renderer)
|
|
121
|
-
define_reload_tool(server, reader, respond, retriever_reloader)
|
|
122
|
-
define_retrieve_tool(server, retriever, respond, respond_err)
|
|
123
|
-
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)
|
|
124
179
|
# Conditionally register collaborator-dependent tools. Historically
|
|
125
180
|
# all 15 stubs were registered unconditionally and returned
|
|
126
181
|
# isError: true when the wiring was missing — that added token
|
|
@@ -128,14 +183,19 @@ module Woods
|
|
|
128
183
|
# try tools guaranteed to fail. Only register when the collaborator
|
|
129
184
|
# is wired, so tools/list reflects what the server can actually do.
|
|
130
185
|
define_session_trace_tool(server, reader, respond, respond_err) if session_tracer_wired?
|
|
131
|
-
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
|
|
132
187
|
define_feedback_tools(server, feedback_store, respond, respond_err, fb_missing) if feedback_store
|
|
133
188
|
define_snapshot_tools(server, snapshot_store, respond, respond_err, snap_missing) if snapshot_store
|
|
134
189
|
define_notion_sync_tool(server, reader, index_dir, respond, respond_err) if notion_wired?
|
|
135
190
|
define_woods_status_tool(server, reader, retriever, index_dir, bootstrap_state, respond)
|
|
136
191
|
register_resource_handler(server, reader)
|
|
192
|
+
ToolContract.apply!(server)
|
|
193
|
+
IndexReaderPinning.install(server, reader: reader)
|
|
137
194
|
|
|
138
|
-
|
|
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)
|
|
139
199
|
end
|
|
140
200
|
|
|
141
201
|
private
|
|
@@ -165,7 +225,7 @@ module Woods
|
|
|
165
225
|
|
|
166
226
|
# Notion export needs both an API token and at least one database ID.
|
|
167
227
|
# A non-blank NOTION_API_TOKEN env var overrides the config token (see
|
|
168
|
-
# docs/
|
|
228
|
+
# docs/NOTION_INTEGRATION.md). Resolution goes through
|
|
169
229
|
# Woods.resolve_notion_token so a blank env var is treated as absent
|
|
170
230
|
# (rather than masking a valid configured token) — matching the
|
|
171
231
|
# exporter and the notion_sync handler.
|
|
@@ -179,7 +239,17 @@ module Woods
|
|
|
179
239
|
end
|
|
180
240
|
|
|
181
241
|
def text_response(text)
|
|
182
|
-
|
|
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
|
+
)
|
|
183
253
|
end
|
|
184
254
|
|
|
185
255
|
# Build a structured error response that carries machine-readable
|
|
@@ -197,7 +267,12 @@ module Woods
|
|
|
197
267
|
meta[:config_key] = config_key if config_key
|
|
198
268
|
meta[:doc_link] = doc_link if doc_link
|
|
199
269
|
meta.merge!(extra) unless extra.empty?
|
|
200
|
-
::MCP::Tool::Response.new(
|
|
270
|
+
::MCP::Tool::Response.new(
|
|
271
|
+
[{ type: 'text', text: message }],
|
|
272
|
+
error: true,
|
|
273
|
+
structured_content: { text: message },
|
|
274
|
+
meta: meta
|
|
275
|
+
)
|
|
201
276
|
end
|
|
202
277
|
|
|
203
278
|
def truncate_section(array, limit)
|
|
@@ -254,7 +329,7 @@ module Woods
|
|
|
254
329
|
# Load a precomputed flow document written by FlowPrecomputer, when
|
|
255
330
|
# `config.precompute_flows` was enabled during extraction. Returns nil
|
|
256
331
|
# when the entry point is missing a method suffix, the JSON file isn't
|
|
257
|
-
# 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
|
|
258
333
|
# FlowAssembler.
|
|
259
334
|
#
|
|
260
335
|
# @param index_dir [String]
|
|
@@ -271,11 +346,27 @@ module Woods
|
|
|
271
346
|
# flows/) using the SAME transform FlowPrecomputer writes with, so a
|
|
272
347
|
# legitimately precomputed flow always resolves to the file on disk.
|
|
273
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.
|
|
274
357
|
path = File.join(index_dir, 'flows', filename)
|
|
275
358
|
return nil unless File.exist?(path)
|
|
276
359
|
|
|
277
|
-
|
|
278
|
-
|
|
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.
|
|
279
370
|
nil
|
|
280
371
|
end
|
|
281
372
|
|
|
@@ -300,6 +391,37 @@ module Woods
|
|
|
300
391
|
container["#{key}_offset"] = offset if offset.positive?
|
|
301
392
|
end
|
|
302
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
|
+
|
|
303
425
|
def define_lookup_tool(server, reader, respond, respond_err, renderer)
|
|
304
426
|
coerce = method(:coerce_array)
|
|
305
427
|
server.define_tool(
|
|
@@ -379,7 +501,7 @@ module Woods
|
|
|
379
501
|
description: 'Restrict scan to these unit types: model, controller, service, job, mailer, etc.'
|
|
380
502
|
},
|
|
381
503
|
fields: {
|
|
382
|
-
type: 'array', items: { type: 'string' },
|
|
504
|
+
type: 'array', items: { type: 'string', enum: %w[identifier metadata source_code] },
|
|
383
505
|
description: 'Fields to search: identifier (default), source_code, metadata'
|
|
384
506
|
},
|
|
385
507
|
limit: { type: 'integer', description: 'Maximum results (default: 20)' },
|
|
@@ -433,6 +555,7 @@ module Woods
|
|
|
433
555
|
def define_traversal_tool(server, reader, respond, renderer, name:, description:, reader_method:, render_key:)
|
|
434
556
|
coerce = method(:coerce_array)
|
|
435
557
|
coerce_int = method(:coerce_integer)
|
|
558
|
+
paginate_nodes = method(:paginate_traversal_nodes)
|
|
436
559
|
server.define_tool(
|
|
437
560
|
name: name,
|
|
438
561
|
description: description,
|
|
@@ -445,25 +568,34 @@ module Woods
|
|
|
445
568
|
description: 'Filter to these types'
|
|
446
569
|
},
|
|
447
570
|
via: {
|
|
448
|
-
|
|
571
|
+
anyOf: [
|
|
572
|
+
{ type: 'string' },
|
|
573
|
+
{ type: 'array', items: { type: 'string' } }
|
|
574
|
+
],
|
|
449
575
|
description: 'Filter by relationship type. Accepts either a single string ' \
|
|
450
576
|
"(e.g. 'code_reference') or an array " \
|
|
451
577
|
"(e.g. ['code_reference','render']); both forms are coerced to an array internally. " \
|
|
452
578
|
'Known values: link_to, redirect_to, form_action, render, code_reference, ' \
|
|
453
|
-
'belongs_to, has_many, has_one, has_and_belongs_to_many.'
|
|
454
|
-
}
|
|
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)' }
|
|
455
584
|
},
|
|
456
585
|
required: ['identifier']
|
|
457
586
|
}
|
|
458
|
-
) 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|
|
|
459
588
|
types = coerce.call(types)
|
|
460
589
|
via = coerce.call(via)
|
|
461
590
|
depth = coerce_int.call(depth)
|
|
591
|
+
limit = coerce_int.call(limit)
|
|
592
|
+
offset = coerce_int.call(offset)
|
|
462
593
|
result = reader.send(reader_method, identifier, depth: depth || 2, types: types, via: via)
|
|
463
594
|
if result[:found] == false
|
|
464
595
|
result[:message] =
|
|
465
596
|
"Identifier '#{identifier}' not found in the index. Use 'search' to find valid identifiers."
|
|
466
597
|
end
|
|
598
|
+
paginate_nodes.call(result, limit || DEFAULT_TRAVERSAL_LIMIT, offset || 0)
|
|
467
599
|
respond.call(renderer.render(render_key, result))
|
|
468
600
|
end
|
|
469
601
|
end
|
|
@@ -492,12 +624,16 @@ module Woods
|
|
|
492
624
|
coerce_int = method(:coerce_integer)
|
|
493
625
|
server.define_tool(
|
|
494
626
|
name: 'graph_analysis',
|
|
495
|
-
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).',
|
|
496
632
|
input_schema: {
|
|
497
633
|
properties: {
|
|
498
634
|
analysis: {
|
|
499
635
|
type: 'string',
|
|
500
|
-
enum: %w[
|
|
636
|
+
enum: ToolResponseRenderer::GRAPH_ANALYSIS_SECTIONS + %w[all],
|
|
501
637
|
description: 'Which analysis to return. Default: all'
|
|
502
638
|
},
|
|
503
639
|
limit: { type: 'integer', description: 'Limit results per section (default: 20)' },
|
|
@@ -514,7 +650,7 @@ module Woods
|
|
|
514
650
|
result = if section == 'all'
|
|
515
651
|
if limit || effective_offset.positive?
|
|
516
652
|
truncated = data.dup
|
|
517
|
-
|
|
653
|
+
ToolResponseRenderer::GRAPH_ANALYSIS_SECTIONS.each do |key|
|
|
518
654
|
paginate.call(truncated, key, limit, effective_offset)
|
|
519
655
|
end
|
|
520
656
|
truncated
|
|
@@ -522,7 +658,7 @@ module Woods
|
|
|
522
658
|
data
|
|
523
659
|
end
|
|
524
660
|
else
|
|
525
|
-
single = { section => data[section], 'stats' => data['stats'] }
|
|
661
|
+
single = { section => data[section] || [], 'stats' => data['stats'] }
|
|
526
662
|
paginate.call(single, section, limit, effective_offset) if limit || effective_offset.positive?
|
|
527
663
|
single
|
|
528
664
|
end
|
|
@@ -658,38 +794,86 @@ module Woods
|
|
|
658
794
|
end
|
|
659
795
|
end
|
|
660
796
|
|
|
661
|
-
def define_reload_tool(server, reader, respond, retriever_reloader)
|
|
797
|
+
def define_reload_tool(server, reader, respond, respond_err, retriever_reloader, bootstrap_state)
|
|
662
798
|
server.define_tool(
|
|
663
799
|
name: 'reload',
|
|
664
800
|
description: 'Reload extraction data from disk. Use after re-running extraction or woods:embed to pick ' \
|
|
665
801
|
'up changes without restarting the server. Refreshes the JSON index (manifest, dependency ' \
|
|
666
802
|
'graph, unit cache) AND re-hydrates the retriever\'s in-memory vector/metadata/graph ' \
|
|
667
|
-
'stores from the latest dumps.
|
|
668
|
-
'
|
|
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.',
|
|
669
808
|
input_schema: { type: 'object', properties: {} }
|
|
670
809
|
) do |server_context:|
|
|
671
|
-
reader.reload!
|
|
672
|
-
manifest = reader.manifest
|
|
673
|
-
payload = {
|
|
674
|
-
reloaded: true,
|
|
675
|
-
extracted_at: manifest['extracted_at'],
|
|
676
|
-
total_units: manifest['total_units'],
|
|
677
|
-
counts: manifest['counts']
|
|
678
|
-
}
|
|
679
810
|
if retriever_reloader
|
|
680
|
-
begin
|
|
681
|
-
|
|
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
|
+
)
|
|
682
827
|
rescue StandardError => e
|
|
683
|
-
|
|
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))
|
|
684
867
|
end
|
|
685
868
|
end
|
|
686
|
-
respond.call(JSON.pretty_generate(payload))
|
|
687
869
|
end
|
|
688
870
|
end
|
|
689
871
|
|
|
690
|
-
def define_retrieve_tool(server, retriever, respond, respond_err)
|
|
872
|
+
def define_retrieve_tool(server, retriever, respond, respond_err, bootstrap_state = nil)
|
|
691
873
|
coerce_int = method(:coerce_integer)
|
|
692
874
|
coerce = method(:coerce_array)
|
|
875
|
+
stale_check = method(:stale_index_result?)
|
|
876
|
+
degraded_response = method(:degraded_retrieval_response)
|
|
693
877
|
server.define_tool(
|
|
694
878
|
name: 'codebase_retrieve',
|
|
695
879
|
description: 'Semantic search: retrieve relevant code units for a natural-language question. ' \
|
|
@@ -750,13 +934,47 @@ module Woods
|
|
|
750
934
|
budget = coerce_int.call(budget)
|
|
751
935
|
types = coerce.call(types)
|
|
752
936
|
exclude_types = coerce.call(exclude_types)
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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'
|
|
759
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
|
|
760
978
|
respond.call(result.context)
|
|
761
979
|
else
|
|
762
980
|
respond_err.call(
|
|
@@ -773,7 +991,51 @@ module Woods
|
|
|
773
991
|
end
|
|
774
992
|
end
|
|
775
993
|
|
|
776
|
-
|
|
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)
|
|
777
1039
|
require_relative '../flow_assembler'
|
|
778
1040
|
require_relative '../flow_document'
|
|
779
1041
|
require_relative '../dependency_graph'
|
|
@@ -803,10 +1065,10 @@ module Woods
|
|
|
803
1065
|
# extraction (gated on `config.precompute_flows`) — it avoids
|
|
804
1066
|
# re-parsing source on every request. Fall back to query-time
|
|
805
1067
|
# reassembly when no precomputed document exists.
|
|
806
|
-
flow_doc = load_precomputed.call(
|
|
1068
|
+
flow_doc = load_precomputed.call(reader.payload_dir, entry_point)
|
|
807
1069
|
flow_doc ||= begin
|
|
808
1070
|
graph = reader.dependency_graph
|
|
809
|
-
assembler = Woods::FlowAssembler.new(graph: graph, extracted_dir:
|
|
1071
|
+
assembler = Woods::FlowAssembler.new(graph: graph, extracted_dir: reader.payload_dir.to_s)
|
|
810
1072
|
assembler.assemble(entry_point, max_depth: max_depth)
|
|
811
1073
|
end
|
|
812
1074
|
|
|
@@ -816,6 +1078,14 @@ module Woods
|
|
|
816
1078
|
# surface it, rather than wrapping the error payload in a
|
|
817
1079
|
# successful response — consistent with session_trace and
|
|
818
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
|
+
|
|
819
1089
|
respond_err.call(
|
|
820
1090
|
"trace_flow failed: #{e.message}",
|
|
821
1091
|
code: :internal_error,
|
|
@@ -847,7 +1117,7 @@ module Woods
|
|
|
847
1117
|
'and set `session_tracer_enabled = true` in Woods.configure.',
|
|
848
1118
|
code: :not_configured,
|
|
849
1119
|
config_key: 'session_store',
|
|
850
|
-
doc_link: 'docs/
|
|
1120
|
+
doc_link: 'docs/MCP_TOOL_COOKBOOK.md#conditional-tools--wiring',
|
|
851
1121
|
tool: 'session_trace'
|
|
852
1122
|
)
|
|
853
1123
|
end
|
|
@@ -869,9 +1139,9 @@ module Woods
|
|
|
869
1139
|
end
|
|
870
1140
|
end
|
|
871
1141
|
|
|
872
|
-
def define_operator_tools(server, operator, respond, respond_err, op_missing)
|
|
873
|
-
define_pipeline_extract_tool(server, operator, respond, respond_err, op_missing)
|
|
874
|
-
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)
|
|
875
1145
|
define_pipeline_status_tool(server, operator, respond, respond_err, op_missing)
|
|
876
1146
|
define_pipeline_diagnose_tool(server, operator, respond, respond_err, op_missing)
|
|
877
1147
|
define_pipeline_repair_tool(server, operator, respond, respond_err, op_missing)
|
|
@@ -884,7 +1154,8 @@ module Woods
|
|
|
884
1154
|
define_retrieval_suggest_tool(server, feedback_store, respond, fb_missing)
|
|
885
1155
|
end
|
|
886
1156
|
|
|
887
|
-
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)
|
|
888
1159
|
server.define_tool(
|
|
889
1160
|
name: 'pipeline_extract',
|
|
890
1161
|
description: 'Trigger a codebase extraction pipeline run. Checks rate limits before proceeding.',
|
|
@@ -919,50 +1190,114 @@ module Woods
|
|
|
919
1190
|
end
|
|
920
1191
|
|
|
921
1192
|
guard = operator[:pipeline_guard]
|
|
922
|
-
if
|
|
923
|
-
next
|
|
924
|
-
'Extraction is rate-limited. Try again later.',
|
|
925
|
-
code: :rate_limited,
|
|
926
|
-
tool: 'pipeline_extract',
|
|
927
|
-
retry_after_seconds: 300
|
|
928
|
-
)
|
|
1193
|
+
if (blocked = cooldown.call(guard, :extraction, 'pipeline_extract'))
|
|
1194
|
+
next blocked
|
|
929
1195
|
end
|
|
930
1196
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
|
941
1208
|
end
|
|
942
1209
|
|
|
943
|
-
|
|
944
|
-
|
|
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
|
|
945
1220
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
|
957
1239
|
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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
|
|
962
1251
|
end
|
|
1252
|
+
acquired
|
|
963
1253
|
end
|
|
964
1254
|
|
|
965
|
-
|
|
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)
|
|
966
1301
|
server.define_tool(
|
|
967
1302
|
name: 'pipeline_embed',
|
|
968
1303
|
description: 'Trigger embedding generation for extracted units. Checks rate limits before proceeding.',
|
|
@@ -975,49 +1310,239 @@ module Woods
|
|
|
975
1310
|
next op_missing.call('pipeline_embed') unless operator
|
|
976
1311
|
|
|
977
1312
|
guard = operator[:pipeline_guard]
|
|
978
|
-
if
|
|
979
|
-
next
|
|
980
|
-
'Embedding is rate-limited. Try again later.',
|
|
981
|
-
code: :rate_limited,
|
|
982
|
-
tool: 'pipeline_embed',
|
|
983
|
-
retry_after_seconds: 300
|
|
984
|
-
)
|
|
1313
|
+
if (blocked = cooldown.call(guard, :embedding, 'pipeline_embed'))
|
|
1314
|
+
next blocked
|
|
985
1315
|
end
|
|
986
1316
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
|
1327
|
+
end
|
|
1328
|
+
|
|
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
|
|
995
1404
|
end
|
|
996
1405
|
|
|
997
|
-
|
|
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
|
|
998
1476
|
|
|
999
1477
|
Thread.new do
|
|
1000
|
-
#
|
|
1001
|
-
#
|
|
1002
|
-
#
|
|
1003
|
-
#
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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).
|
|
1008
1494
|
logger = defined?(Rails) ? Rails.logger : Logger.new($stderr)
|
|
1009
|
-
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
|
|
1010
1500
|
ensure
|
|
1011
|
-
|
|
1501
|
+
lock&.release
|
|
1502
|
+
Woods::MCP::Server.send(:pipeline_finish, kind)
|
|
1012
1503
|
end
|
|
1504
|
+
handoff = true
|
|
1013
1505
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
|
1018
1521
|
end
|
|
1019
1522
|
end
|
|
1020
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
|
+
|
|
1021
1546
|
# Acquire a pipeline-kind lock atomically. Returns false when
|
|
1022
1547
|
# another thread is already running that kind of pipeline (so the
|
|
1023
1548
|
# caller can refuse the new request instead of racing the running
|
|
@@ -1117,8 +1642,21 @@ module Woods
|
|
|
1117
1642
|
when 'clear_locks'
|
|
1118
1643
|
lock = operator[:pipeline_lock]
|
|
1119
1644
|
if lock
|
|
1120
|
-
lock.
|
|
1121
|
-
|
|
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
|
|
1122
1660
|
else
|
|
1123
1661
|
respond_err.call(
|
|
1124
1662
|
'Pipeline lock is not configured.',
|
|
@@ -1128,7 +1666,22 @@ module Woods
|
|
|
1128
1666
|
)
|
|
1129
1667
|
end
|
|
1130
1668
|
when 'reset_cooldowns'
|
|
1131
|
-
|
|
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
|
|
1132
1685
|
else
|
|
1133
1686
|
respond_err.call(
|
|
1134
1687
|
"Unknown repair action: #{action}",
|
|
@@ -1352,7 +1905,7 @@ module Woods
|
|
|
1352
1905
|
'notion_api_token is not configured. Set it in Woods.configure or via the NOTION_API_TOKEN env var.',
|
|
1353
1906
|
code: :not_configured,
|
|
1354
1907
|
config_key: 'notion_api_token',
|
|
1355
|
-
doc_link: 'docs/
|
|
1908
|
+
doc_link: 'docs/NOTION_INTEGRATION.md',
|
|
1356
1909
|
tool: 'notion_sync'
|
|
1357
1910
|
)
|
|
1358
1911
|
end
|
|
@@ -1362,7 +1915,7 @@ module Woods
|
|
|
1362
1915
|
'notion_database_ids is not configured. Set it in Woods.configure.',
|
|
1363
1916
|
code: :not_configured,
|
|
1364
1917
|
config_key: 'notion_database_ids',
|
|
1365
|
-
doc_link: 'docs/
|
|
1918
|
+
doc_link: 'docs/NOTION_INTEGRATION.md',
|
|
1366
1919
|
tool: 'notion_sync'
|
|
1367
1920
|
)
|
|
1368
1921
|
end
|
|
@@ -1452,6 +2005,20 @@ module Woods
|
|
|
1452
2005
|
# status claiming +embedding_model: "text-embedding-3-small"+ next to
|
|
1453
2006
|
# +embedding_provider: "ollama"+ and reasonably distrust every field.
|
|
1454
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)
|
|
1455
2022
|
manifest = safe_manifest(reader)
|
|
1456
2023
|
extracted_at = manifest && manifest['extracted_at']
|
|
1457
2024
|
staleness = staleness_seconds(extracted_at)
|
|
@@ -1469,7 +2036,8 @@ module Woods
|
|
|
1469
2036
|
index_dir: index_dir.to_s,
|
|
1470
2037
|
update: Woods::UpdateCheck.status_hash
|
|
1471
2038
|
},
|
|
1472
|
-
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),
|
|
1473
2041
|
retriever: {
|
|
1474
2042
|
configured: !retriever.nil?,
|
|
1475
2043
|
class: retriever&.class&.name
|
|
@@ -1498,7 +2066,7 @@ module Woods
|
|
|
1498
2066
|
# diff directly. This is an observability signal, not a hard gate —
|
|
1499
2067
|
# hard-refusing responses would be much more disruptive than a loudly-
|
|
1500
2068
|
# visible staleness flag that agents can branch on.
|
|
1501
|
-
def index_section(manifest, extracted_at, staleness, index_dir)
|
|
2069
|
+
def index_section(manifest, extracted_at, staleness, index_dir, reader = nil)
|
|
1502
2070
|
base = {
|
|
1503
2071
|
extracted_at: extracted_at,
|
|
1504
2072
|
staleness_seconds: staleness,
|
|
@@ -1512,6 +2080,9 @@ module Woods
|
|
|
1512
2080
|
schema_sha: manifest && manifest['schema_sha']
|
|
1513
2081
|
}
|
|
1514
2082
|
|
|
2083
|
+
base.merge!(generation_fields(index_dir, reader))
|
|
2084
|
+
base.merge!(working_tree_fields(index_dir))
|
|
2085
|
+
|
|
1515
2086
|
manifest_sha = manifest && manifest['git_sha']
|
|
1516
2087
|
head_sha = manifest_sha ? resolve_head_sha(index_dir) : nil
|
|
1517
2088
|
return base unless head_sha
|
|
@@ -1521,20 +2092,156 @@ module Woods
|
|
|
1521
2092
|
base
|
|
1522
2093
|
end
|
|
1523
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
|
+
|
|
1524
2227
|
# Resolve the current HEAD SHA for the git repo containing +index_dir+.
|
|
1525
2228
|
# Returns nil when git is unavailable or +index_dir+ is not in a repo —
|
|
1526
2229
|
# callers treat nil as "can't compare" rather than "mismatch".
|
|
1527
2230
|
#
|
|
1528
|
-
#
|
|
1529
|
-
#
|
|
1530
|
-
#
|
|
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.
|
|
1531
2238
|
def resolve_head_sha(index_dir)
|
|
1532
2239
|
return nil unless index_dir
|
|
1533
2240
|
|
|
1534
2241
|
dir = index_dir.to_s
|
|
1535
2242
|
return nil unless File.directory?(dir)
|
|
1536
2243
|
|
|
1537
|
-
output, status = Open3.
|
|
2244
|
+
output, _stderr, status = Open3.capture3('git', '-C', dir, 'rev-parse', 'HEAD')
|
|
1538
2245
|
status.success? ? output.strip : nil
|
|
1539
2246
|
rescue Errno::ENOENT, Errno::EACCES
|
|
1540
2247
|
# git not installed or not executable on this host — equivalent to
|
|
@@ -1614,28 +2321,74 @@ module Woods
|
|
|
1614
2321
|
def register_resource_handler(server, reader)
|
|
1615
2322
|
server.resources_read_handler do |params|
|
|
1616
2323
|
uri = params[:uri]
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
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)
|
|
1636
2378
|
end
|
|
1637
2379
|
end
|
|
1638
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
|
|
1639
2392
|
end
|
|
1640
2393
|
end
|
|
1641
2394
|
end
|