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
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'date'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'time'
|
|
7
|
+
require 'fileutils'
|
|
8
|
+
require 'open3'
|
|
9
|
+
require_relative '../../atomic_file'
|
|
10
|
+
|
|
11
|
+
module Woods
|
|
12
|
+
module MCP
|
|
13
|
+
# The MCP Tasks extension (`io.modelcontextprotocol/tasks`).
|
|
14
|
+
#
|
|
15
|
+
# Distinct from {Woods::Tasks}, which is the rake-helper namespace — this is
|
|
16
|
+
# the protocol extension for long-running operations.
|
|
17
|
+
module Tasks
|
|
18
|
+
# Durable registry for long-running tool invocations.
|
|
19
|
+
#
|
|
20
|
+
# The whole point of a task over a bare background thread is that the
|
|
21
|
+
# handle outlives the thing that created it: a client may disconnect,
|
|
22
|
+
# restart, and resume polling, and it must get a real answer. So records
|
|
23
|
+
# live on disk rather than in a process-local hash.
|
|
24
|
+
#
|
|
25
|
+
# **Why this does not take `PipelineLock`.** Every other writer against the
|
|
26
|
+
# index directory serializes on that lock, because they rewrite one shared
|
|
27
|
+
# artifact set (units + dependency graph + generation) where each write is
|
|
28
|
+
# individually atomic but the *set* is not. Task records share nothing:
|
|
29
|
+
# one file per task, written whole via {AtomicFile}, never read as a set
|
|
30
|
+
# that must agree. Taking the lock here would also deadlock outright —
|
|
31
|
+
# `pipeline_extract` holds it for the duration of the run, which is
|
|
32
|
+
# exactly when its task record needs updating.
|
|
33
|
+
class Store
|
|
34
|
+
class CorruptRecordError < StandardError; end
|
|
35
|
+
class ProducerIdentityError < IOError; end
|
|
36
|
+
|
|
37
|
+
# Subdirectory of the index directory holding one JSON file per task.
|
|
38
|
+
DIRNAME = 'tasks'
|
|
39
|
+
|
|
40
|
+
# How long a terminal record stays readable. Long enough that a client
|
|
41
|
+
# which dropped mid-run can reconnect and still collect its result.
|
|
42
|
+
DEFAULT_TTL_MS = 3_600_000 # 1 hour
|
|
43
|
+
|
|
44
|
+
# Suggested client poll cadence. Extraction on a large host takes
|
|
45
|
+
# minutes, so a tight poll buys nothing but load.
|
|
46
|
+
DEFAULT_POLL_INTERVAL_MS = 2_000
|
|
47
|
+
|
|
48
|
+
JSON_RPC_INTERNAL_ERROR = -32_603
|
|
49
|
+
|
|
50
|
+
# How long a `working` record minted by a producer this reader cannot
|
|
51
|
+
# judge (foreign boot id or pid namespace) is believed on age alone.
|
|
52
|
+
#
|
|
53
|
+
# The pid-table check is unusable across boots and namespaces, so the
|
|
54
|
+
# only two readings of a foreign identity on the SAME store are "this
|
|
55
|
+
# machine rebooted while a run was in flight" (the producer is dead by
|
|
56
|
+
# construction) and "another machine is writing this index over a
|
|
57
|
+
# shared filesystem" (it may still be running). Nothing on disk tells
|
|
58
|
+
# them apart, so age decides: wider than any plausible extraction or
|
|
59
|
+
# embed run, narrow enough that a rebooted host stops answering
|
|
60
|
+
# `working` forever.
|
|
61
|
+
FOREIGN_PRODUCER_GRACE_SECONDS = 86_400 # 24 hours
|
|
62
|
+
|
|
63
|
+
# Terminal states: "once reached, the task's state does not change".
|
|
64
|
+
TERMINAL = %w[completed failed cancelled].freeze
|
|
65
|
+
STATUSES = (%w[working input_required] + TERMINAL).freeze
|
|
66
|
+
|
|
67
|
+
# A task id is minted by {SecureRandom} and only ever compared against
|
|
68
|
+
# this, so anything that could escape the directory is not a task id.
|
|
69
|
+
SAFE_ID = /\A[a-f0-9]{32}\z/
|
|
70
|
+
LINUX_PROCESS_STATE = /\A[RSDZTtXxKWPI]\z/
|
|
71
|
+
LINUX_NUMERIC_FIELD = /\A-?\d+\z/
|
|
72
|
+
private_constant :LINUX_PROCESS_STATE, :LINUX_NUMERIC_FIELD
|
|
73
|
+
|
|
74
|
+
# One task record.
|
|
75
|
+
Task = Struct.new(
|
|
76
|
+
:id, :tool, :status, :created_at, :updated_at, :ttl_ms,
|
|
77
|
+
:poll_interval_ms, :status_message, :result, :error, :pid,
|
|
78
|
+
:producer_identity, :input_requests,
|
|
79
|
+
keyword_init: true
|
|
80
|
+
) do
|
|
81
|
+
def terminal?
|
|
82
|
+
TERMINAL.include?(status)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Wire shape for `CreateTaskResult` and `tasks/get`.
|
|
86
|
+
#
|
|
87
|
+
# @return [Hash]
|
|
88
|
+
def to_h
|
|
89
|
+
wire = {
|
|
90
|
+
taskId: id,
|
|
91
|
+
status: status,
|
|
92
|
+
pollIntervalMs: poll_interval_ms,
|
|
93
|
+
createdAt: created_at,
|
|
94
|
+
lastUpdatedAt: updated_at,
|
|
95
|
+
statusMessage: status_message,
|
|
96
|
+
result: result,
|
|
97
|
+
error: error,
|
|
98
|
+
inputRequests: input_requests
|
|
99
|
+
}.compact
|
|
100
|
+
wire[:ttlMs] = ttl_ms
|
|
101
|
+
wire
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# On-disk shape: every field, snake_case, so a record round-trips
|
|
105
|
+
# without the wire shape's renaming and omissions.
|
|
106
|
+
#
|
|
107
|
+
# @return [Hash]
|
|
108
|
+
def to_h_record
|
|
109
|
+
each_pair.to_h.compact
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param index_dir [String, Pathname] the Woods index directory
|
|
114
|
+
def initialize(index_dir)
|
|
115
|
+
@dir = File.join(index_dir.to_s, DIRNAME)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Create and durably persist a new task.
|
|
119
|
+
#
|
|
120
|
+
# @param tool [String] the tool whose invocation this represents
|
|
121
|
+
# @param ttl_ms [Integer]
|
|
122
|
+
# @param poll_interval_ms [Integer]
|
|
123
|
+
# @return [Task]
|
|
124
|
+
def create!(tool:, ttl_ms: DEFAULT_TTL_MS, poll_interval_ms: DEFAULT_POLL_INTERVAL_MS)
|
|
125
|
+
sweep_expired!
|
|
126
|
+
producer_identity = producer_identity_for(Process.pid)
|
|
127
|
+
raise ProducerIdentityError, 'Could not establish producer process identity.' unless producer_identity
|
|
128
|
+
|
|
129
|
+
now = Time.now.utc.iso8601
|
|
130
|
+
task = Task.new(
|
|
131
|
+
id: SecureRandom.hex(16), tool: tool, status: 'working',
|
|
132
|
+
created_at: now, updated_at: now, ttl_ms: ttl_ms,
|
|
133
|
+
poll_interval_ms: poll_interval_ms, pid: Process.pid,
|
|
134
|
+
producer_identity: producer_identity
|
|
135
|
+
)
|
|
136
|
+
write(task)
|
|
137
|
+
task
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Read a task, resolving expiry and crashed owners along the way.
|
|
141
|
+
#
|
|
142
|
+
# @param id [String]
|
|
143
|
+
# @return [Task, nil] nil when unknown, expired, or unreadable
|
|
144
|
+
def get(id)
|
|
145
|
+
task = read(id)
|
|
146
|
+
return nil unless task
|
|
147
|
+
return nil if expired?(task)
|
|
148
|
+
|
|
149
|
+
adopt_orphan(task)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @param id [String]
|
|
153
|
+
# @param result [Hash] what the synchronous call would have returned
|
|
154
|
+
# @return [Task, nil] the updated record, or nil if it was already terminal
|
|
155
|
+
def complete!(id, result:)
|
|
156
|
+
transition!(id, 'completed') { |t| t.result = result }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @param id [String]
|
|
160
|
+
# @param message [String]
|
|
161
|
+
# @return [Task, nil] the updated record, or nil if it was already terminal
|
|
162
|
+
def fail!(id, message:)
|
|
163
|
+
transition!(id, 'failed') do |t|
|
|
164
|
+
t.error = {
|
|
165
|
+
'code' => JSON_RPC_INTERNAL_ERROR,
|
|
166
|
+
'message' => message.to_s
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def path_for(id)
|
|
174
|
+
return nil unless id.is_a?(String) && id.match?(SAFE_ID)
|
|
175
|
+
|
|
176
|
+
File.join(@dir, "#{id}.json")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def read(id)
|
|
180
|
+
path = path_for(id)
|
|
181
|
+
return nil unless path && File.exist?(path)
|
|
182
|
+
|
|
183
|
+
data = JSON.parse(Woods::AtomicFile.read(path))
|
|
184
|
+
raise CorruptRecordError, "Invalid task record #{id}: schema mismatch" unless valid_record?(data, id)
|
|
185
|
+
|
|
186
|
+
Task.new(
|
|
187
|
+
id: data['id'], tool: data['tool'], status: data['status'],
|
|
188
|
+
created_at: data['created_at'], updated_at: data['updated_at'],
|
|
189
|
+
ttl_ms: data['ttl_ms'], poll_interval_ms: data['poll_interval_ms'],
|
|
190
|
+
status_message: data['status_message'], result: data['result'],
|
|
191
|
+
error: data['error'], pid: data['pid'],
|
|
192
|
+
producer_identity: data['producer_identity'], input_requests: data['input_requests']
|
|
193
|
+
)
|
|
194
|
+
rescue JSON::ParserError, SystemCallError, TypeError => e
|
|
195
|
+
raise CorruptRecordError, "Invalid task record #{id}: #{e.class}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Persisted shape is the struct's own fields, not {Task#to_h} — that is
|
|
199
|
+
# the wire shape, which drops `pid` and `tool` and renames the rest.
|
|
200
|
+
def write(task)
|
|
201
|
+
FileUtils.mkdir_p(@dir)
|
|
202
|
+
Woods::AtomicFile.write(path_for(task.id), JSON.pretty_generate(task.to_h_record))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def valid_record?(data, expected_id)
|
|
206
|
+
return false unless data.is_a?(Hash)
|
|
207
|
+
return false unless data['id'] == expected_id && data['id'].match?(SAFE_ID)
|
|
208
|
+
return false unless data['tool'].is_a?(String) && !data['tool'].empty?
|
|
209
|
+
return false unless STATUSES.include?(data['status'])
|
|
210
|
+
return false unless valid_time?(data['created_at']) && valid_time?(data['updated_at'])
|
|
211
|
+
return false unless data['ttl_ms'].nil? ||
|
|
212
|
+
(data['ttl_ms'].is_a?(Integer) && data['ttl_ms'] >= 0)
|
|
213
|
+
return false unless data['poll_interval_ms'].nil? ||
|
|
214
|
+
(data['poll_interval_ms'].is_a?(Integer) && data['poll_interval_ms'].positive?)
|
|
215
|
+
return false unless data['status_message'].nil? || data['status_message'].is_a?(String)
|
|
216
|
+
|
|
217
|
+
valid_status_fields?(data)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def valid_status_fields?(data)
|
|
221
|
+
case data['status']
|
|
222
|
+
when 'working'
|
|
223
|
+
valid_producer?(data) && absent?(data, 'result', 'error', 'input_requests')
|
|
224
|
+
when 'input_required'
|
|
225
|
+
valid_producer?(data) && absent?(data, 'result', 'error') && valid_input_requests?(data['input_requests'])
|
|
226
|
+
when 'completed'
|
|
227
|
+
data['result'].is_a?(Hash) && absent?(data, 'error', 'input_requests')
|
|
228
|
+
when 'failed'
|
|
229
|
+
valid_error?(data['error']) && absent?(data, 'result', 'input_requests')
|
|
230
|
+
when 'cancelled'
|
|
231
|
+
absent?(data, 'result', 'error', 'input_requests')
|
|
232
|
+
else
|
|
233
|
+
false
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def valid_producer?(data)
|
|
238
|
+
data['pid'].is_a?(Integer) && data['pid'].positive? &&
|
|
239
|
+
data['producer_identity'].is_a?(String) && !data['producer_identity'].empty?
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def valid_error?(error)
|
|
243
|
+
error.is_a?(Hash) && error['code'].is_a?(Integer) &&
|
|
244
|
+
error['message'].is_a?(String) && !error['message'].empty?
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def valid_input_requests?(requests)
|
|
248
|
+
requests.is_a?(Hash) && !requests.empty? && requests.values.all? do |request|
|
|
249
|
+
request.is_a?(Hash) && request['method'].is_a?(String) && request['params'].is_a?(Hash)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def absent?(data, *keys)
|
|
254
|
+
keys.all? { |key| data[key].nil? }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def valid_time?(value)
|
|
258
|
+
return false unless value.is_a?(String)
|
|
259
|
+
|
|
260
|
+
Time.iso8601(value)
|
|
261
|
+
true
|
|
262
|
+
rescue ArgumentError
|
|
263
|
+
false
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# @return [Task, nil] the updated record, or nil when the transition was
|
|
267
|
+
# refused — returning the record rather than a bare boolean means a
|
|
268
|
+
# caller that wants the new state does not have to re-read it.
|
|
269
|
+
def transition!(id, status, &block)
|
|
270
|
+
with_task_lock(id) do
|
|
271
|
+
task = read(id)
|
|
272
|
+
return nil if task.nil?
|
|
273
|
+
# Terminal is terminal: a thread that finishes after the client
|
|
274
|
+
# cancelled must not overwrite the state the client already saw.
|
|
275
|
+
return nil if task.terminal?
|
|
276
|
+
|
|
277
|
+
task.status = status
|
|
278
|
+
task.updated_at = Time.now.utc.iso8601
|
|
279
|
+
block&.call(task)
|
|
280
|
+
write(task)
|
|
281
|
+
task
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def with_task_lock(id)
|
|
286
|
+
path = path_for(id)
|
|
287
|
+
return nil unless path
|
|
288
|
+
|
|
289
|
+
FileUtils.mkdir_p(@dir)
|
|
290
|
+
File.open("#{path}.lock", File::RDWR | File::CREAT, 0o600) do |file|
|
|
291
|
+
file.flock(File::LOCK_EX)
|
|
292
|
+
yield
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Only terminal records expire. An unfinished task must outlive its ttl
|
|
297
|
+
# rather than vanish mid-run and strand a client that is still polling.
|
|
298
|
+
#
|
|
299
|
+
# Measured from `updated_at` — the terminal transition itself — not
|
|
300
|
+
# `created_at`. A pipeline that runs longer than ttl_ms would otherwise
|
|
301
|
+
# be born expired: `complete!` writes the terminal record at the run's
|
|
302
|
+
# end, and if expiry counted from the start, the very next `tasks/get`
|
|
303
|
+
# would report it unknown before the client ever saw the result.
|
|
304
|
+
def expired?(task)
|
|
305
|
+
return false unless task.terminal?
|
|
306
|
+
return false if task.ttl_ms.nil?
|
|
307
|
+
|
|
308
|
+
Time.now.utc - Time.parse(task.updated_at) > (task.ttl_ms / 1000.0)
|
|
309
|
+
rescue ArgumentError, TypeError
|
|
310
|
+
false
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# A `working` record whose owning process is gone describes work that
|
|
314
|
+
# cannot still be happening. Resolve it to `failed` and persist, so
|
|
315
|
+
# every later reader gets the same answer instead of re-deciding.
|
|
316
|
+
def adopt_orphan(task)
|
|
317
|
+
return task unless task.status == 'working'
|
|
318
|
+
return task if producer_alive?(task)
|
|
319
|
+
|
|
320
|
+
task.status = 'failed'
|
|
321
|
+
task.error = {
|
|
322
|
+
'code' => JSON_RPC_INTERNAL_ERROR,
|
|
323
|
+
'message' => "The process running this task did not survive (pid #{task.pid}). " \
|
|
324
|
+
'Re-run the tool; the index is unchanged unless the run had already committed.'
|
|
325
|
+
}
|
|
326
|
+
task.updated_at = Time.now.utc.iso8601
|
|
327
|
+
write(task)
|
|
328
|
+
task
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def producer_alive?(task)
|
|
332
|
+
# A foreign-boot producer identity (e.g. a task minted inside a
|
|
333
|
+
# container) can't be judged by this reader's own pid table at all —
|
|
334
|
+
# `task.pid` names a slot in a namespace this process doesn't share,
|
|
335
|
+
# so `process_alive?` would be checking an unrelated, possibly
|
|
336
|
+
# reused, host pid. Leave those tasks alone rather than risk failing
|
|
337
|
+
# one that is still running — but only within
|
|
338
|
+
# FOREIGN_PRODUCER_GRACE_SECONDS, or a rebooted host answers
|
|
339
|
+
# `working` forever for a run that died in the reboot.
|
|
340
|
+
return foreign_producer_within_grace?(task) if foreign_producer?(task.producer_identity)
|
|
341
|
+
|
|
342
|
+
process_alive?(task.pid) && producer_identity_for(task.pid) == task.producer_identity
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Age backstop for an unjudgeable producer, measured from
|
|
346
|
+
# `updated_at` (the last sign of life this record carries) exactly as
|
|
347
|
+
# {#expired?} measures terminal TTL. An unparseable timestamp keeps the
|
|
348
|
+
# conservative answer.
|
|
349
|
+
def foreign_producer_within_grace?(task)
|
|
350
|
+
Time.now.utc - Time.parse(task.updated_at) <= FOREIGN_PRODUCER_GRACE_SECONDS
|
|
351
|
+
rescue ArgumentError, TypeError
|
|
352
|
+
true
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# A producer this reader cannot judge by its own pid table: minted
|
|
356
|
+
# under another kernel boot, or (ordinary Docker on Linux, which
|
|
357
|
+
# shares the host boot id) inside another pid namespace. `task.pid`
|
|
358
|
+
# then names a slot in a namespace this process does not share, so
|
|
359
|
+
# `process_alive?` would be checking an unrelated, possibly reused,
|
|
360
|
+
# host pid.
|
|
361
|
+
def foreign_producer?(producer_identity)
|
|
362
|
+
boot = producer_identity[/\Aboot=([^;]+)/, 1]
|
|
363
|
+
return false unless boot
|
|
364
|
+
return true if boot != current_boot_identity
|
|
365
|
+
|
|
366
|
+
namespace = producer_identity[/;ns=([^;]+)/, 1]
|
|
367
|
+
return false if namespace.nil? || current_pid_namespace.nil?
|
|
368
|
+
|
|
369
|
+
namespace != current_pid_namespace
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# @return [String, nil] this process's pid namespace token
|
|
373
|
+
# (`pid:[4026531836]`), nil where /proc has none (Darwin)
|
|
374
|
+
def current_pid_namespace
|
|
375
|
+
return @current_pid_namespace if defined?(@current_pid_namespace)
|
|
376
|
+
|
|
377
|
+
@current_pid_namespace = pid_namespace_for('self')
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def pid_namespace_for(pid)
|
|
381
|
+
File.readlink("/proc/#{pid}/ns/pid")
|
|
382
|
+
rescue SystemCallError
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def current_boot_identity
|
|
387
|
+
return @current_boot_identity if defined?(@current_boot_identity)
|
|
388
|
+
|
|
389
|
+
@current_boot_identity = if File.readable?('/proc/sys/kernel/random/boot_id')
|
|
390
|
+
boot_id = File.read('/proc/sys/kernel/random/boot_id').strip
|
|
391
|
+
boot_id.empty? ? nil : boot_id
|
|
392
|
+
else
|
|
393
|
+
darwin_boot_identity
|
|
394
|
+
end
|
|
395
|
+
rescue SystemCallError
|
|
396
|
+
@current_boot_identity = nil
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def process_alive?(pid)
|
|
400
|
+
Process.kill(0, Integer(pid))
|
|
401
|
+
true
|
|
402
|
+
rescue Errno::EPERM
|
|
403
|
+
# The process exists, it just belongs to another user. Alive.
|
|
404
|
+
true
|
|
405
|
+
rescue Errno::ESRCH, ArgumentError, TypeError
|
|
406
|
+
false
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def producer_identity_for(pid)
|
|
410
|
+
pid = Integer(pid)
|
|
411
|
+
return linux_process_identity(pid) if File.readable?('/proc/sys/kernel/random/boot_id')
|
|
412
|
+
|
|
413
|
+
darwin_process_identity(pid)
|
|
414
|
+
rescue SystemCallError, ArgumentError, TypeError
|
|
415
|
+
nil
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def linux_process_identity(pid)
|
|
419
|
+
boot_id = File.read('/proc/sys/kernel/random/boot_id').strip
|
|
420
|
+
start_ticks = linux_start_ticks(File.read("/proc/#{pid}/stat"))
|
|
421
|
+
return if boot_id.empty? || start_ticks.nil?
|
|
422
|
+
|
|
423
|
+
namespace = pid_namespace_for(pid)
|
|
424
|
+
namespace ? "boot=#{boot_id};ns=#{namespace};start_ticks=#{start_ticks}" : "boot=#{boot_id};start_ticks=#{start_ticks}"
|
|
425
|
+
rescue Errno::ENOENT
|
|
426
|
+
nil
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def linux_start_ticks(stat)
|
|
430
|
+
boundary = stat.rindex(') ')
|
|
431
|
+
return unless boundary && stat.match?(/\A\d+ \(/)
|
|
432
|
+
|
|
433
|
+
fields = stat[(boundary + 2)..].split
|
|
434
|
+
return unless fields.length >= 20
|
|
435
|
+
return unless fields.first.match?(LINUX_PROCESS_STATE)
|
|
436
|
+
|
|
437
|
+
numeric_fields = fields[1, 19]
|
|
438
|
+
return unless numeric_fields.all? { |field| field.match?(LINUX_NUMERIC_FIELD) }
|
|
439
|
+
|
|
440
|
+
start_ticks = numeric_fields.last
|
|
441
|
+
start_ticks if start_ticks.match?(/\A\d+\z/)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def darwin_process_identity(pid)
|
|
445
|
+
environment = { 'LC_ALL' => 'C', 'LANG' => 'C', 'TZ' => 'UTC' }
|
|
446
|
+
started, ps_status = Open3.capture2(environment, '/bin/ps', '-o', 'lstart=', '-p', pid.to_s)
|
|
447
|
+
return unless ps_status.success?
|
|
448
|
+
|
|
449
|
+
start_epoch = DateTime.strptime(started.strip, '%a %b %e %H:%M:%S %Y').to_time.to_i
|
|
450
|
+
boot_identity = darwin_boot_identity
|
|
451
|
+
return unless boot_identity
|
|
452
|
+
|
|
453
|
+
"boot=#{boot_identity};start=#{start_epoch}"
|
|
454
|
+
rescue Date::Error
|
|
455
|
+
nil
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def darwin_boot_identity
|
|
459
|
+
return @darwin_boot_identity if defined?(@darwin_boot_identity)
|
|
460
|
+
|
|
461
|
+
booted, status = Open3.capture2('/usr/sbin/sysctl', '-n', 'kern.boottime')
|
|
462
|
+
return unless status.success?
|
|
463
|
+
|
|
464
|
+
boot_match = booted.match(/sec = (\d+), usec = (\d+)/)
|
|
465
|
+
return unless boot_match
|
|
466
|
+
|
|
467
|
+
@darwin_boot_identity = "#{boot_match[1]}.#{boot_match[2]}"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def sweep_expired!
|
|
471
|
+
return unless Dir.exist?(@dir)
|
|
472
|
+
|
|
473
|
+
Dir.glob(File.join(@dir, '*.json')).each do |path|
|
|
474
|
+
id = File.basename(path, '.json')
|
|
475
|
+
task = read(id)
|
|
476
|
+
next unless task.nil? || expired?(task)
|
|
477
|
+
|
|
478
|
+
File.delete(path)
|
|
479
|
+
FileUtils.rm_f("#{path}.lock")
|
|
480
|
+
rescue SystemCallError
|
|
481
|
+
# Another process swept it first; nothing to do.
|
|
482
|
+
nil
|
|
483
|
+
rescue CorruptRecordError
|
|
484
|
+
# Not a race — a valid record does not spontaneously fail schema
|
|
485
|
+
# validation. This is a record that will never parse (a version
|
|
486
|
+
# upgrade changed the schema, a hand-edited file, a torn write
|
|
487
|
+
# that never got a follow-up write). Left alone, sweep_expired!
|
|
488
|
+
# re-parses and re-fails on it every time it runs, forever. Delete
|
|
489
|
+
# it once it's old enough that a torn write from a concurrent
|
|
490
|
+
# create!/write would have long since finished; a record younger
|
|
491
|
+
# than the default TTL is left alone in case it's mid-write.
|
|
492
|
+
next unless corrupt_record_expired?(path)
|
|
493
|
+
|
|
494
|
+
File.delete(path)
|
|
495
|
+
FileUtils.rm_f("#{path}.lock")
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Is a corrupt record old enough to delete outright?
|
|
500
|
+
#
|
|
501
|
+
# {#expired?} can't be used here — it reads +task.created_at+ from a
|
|
502
|
+
# successfully parsed {Task}, which is exactly what a corrupt record
|
|
503
|
+
# doesn't have. File mtime is the next best signal: {#write} rewrites
|
|
504
|
+
# the whole file on every transition, so mtime tracks "how long ago
|
|
505
|
+
# this record was last touched", which is what distinguishes a
|
|
506
|
+
# permanently broken record from a write still in flight.
|
|
507
|
+
#
|
|
508
|
+
# @param path [String] the corrupt record's file path
|
|
509
|
+
# @return [Boolean]
|
|
510
|
+
def corrupt_record_expired?(path)
|
|
511
|
+
Time.now.utc - File.mtime(path).utc > (DEFAULT_TTL_MS / 1000.0)
|
|
512
|
+
rescue SystemCallError
|
|
513
|
+
false
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'mcp'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module MCP
|
|
8
|
+
# Applies the shared wire contract to every Index MCP tool after conditional
|
|
9
|
+
# registration has finished.
|
|
10
|
+
module ToolContract
|
|
11
|
+
TASK_RESULT_TOOLS = %w[pipeline_embed pipeline_extract].freeze
|
|
12
|
+
|
|
13
|
+
INTEGER_BOUNDS = {
|
|
14
|
+
'budget' => [1, 200_000],
|
|
15
|
+
'depth' => [0, 20],
|
|
16
|
+
'limit' => [1, 1_000],
|
|
17
|
+
'min_size' => [1, 100_000],
|
|
18
|
+
'offset' => [0, 1_000_000],
|
|
19
|
+
'score' => [1, 5]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
OUTPUT_SCHEMA = {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
text: { type: 'string' },
|
|
26
|
+
data: {
|
|
27
|
+
type: %w[object array string number boolean null],
|
|
28
|
+
description: 'Parsed JSON payload when the selected renderer emits JSON.'
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
required: ['text'],
|
|
32
|
+
additionalProperties: false
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
def apply!(server)
|
|
37
|
+
server.tools.each do |name, tool|
|
|
38
|
+
tool.input_schema(close_input_schema(tool.input_schema_value.to_h, name))
|
|
39
|
+
tool.output_schema(OUTPUT_SCHEMA) unless TASK_RESULT_TOOLS.include?(name)
|
|
40
|
+
end
|
|
41
|
+
server.configuration.validate_tool_call_results = true
|
|
42
|
+
server.singleton_class.prepend(Dispatch)
|
|
43
|
+
server
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def artifact_error?(error)
|
|
47
|
+
current = error
|
|
48
|
+
while current
|
|
49
|
+
return true if current.is_a?(JSON::ParserError) || current.is_a?(EncodingError) ||
|
|
50
|
+
current.is_a?(SystemCallError) || current.is_a?(IOError)
|
|
51
|
+
|
|
52
|
+
nested = current.respond_to?(:original_error) ? current.original_error : nil
|
|
53
|
+
nested ||= current.cause
|
|
54
|
+
break if nested.equal?(current)
|
|
55
|
+
|
|
56
|
+
current = nested
|
|
57
|
+
end
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def close_input_schema(source, tool_name)
|
|
64
|
+
schema = JSON.parse(JSON.generate(source), symbolize_names: true)
|
|
65
|
+
schema.delete(:$schema)
|
|
66
|
+
schema[:type] = 'object'
|
|
67
|
+
schema[:properties] ||= {}
|
|
68
|
+
schema[:additionalProperties] = false
|
|
69
|
+
bound_properties!(schema, tool_name)
|
|
70
|
+
schema
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def bound_properties!(schema, tool_name)
|
|
74
|
+
required = Array(schema[:required]).map(&:to_s)
|
|
75
|
+
schema[:properties].each do |name, property|
|
|
76
|
+
Array(property[:anyOf]).each { |branch| bound_property!(name, branch, required, tool_name) }
|
|
77
|
+
bound_property!(name, property, required, tool_name)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def bound_property!(name, property, required, tool_name)
|
|
82
|
+
case property[:type]
|
|
83
|
+
when 'integer'
|
|
84
|
+
minimum, maximum = integer_bounds(name, tool_name)
|
|
85
|
+
property[:minimum] = minimum
|
|
86
|
+
property[:maximum] = maximum
|
|
87
|
+
when 'string'
|
|
88
|
+
property[:minLength] = 1 if required.include?(name.to_s)
|
|
89
|
+
property[:maxLength] = 10_000
|
|
90
|
+
when 'array'
|
|
91
|
+
property[:maxItems] = 1_000
|
|
92
|
+
property[:items][:maxLength] ||= 10_000 if property.dig(:items, :type) == 'string'
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Every integer argument on the tool surface must declare its own
|
|
97
|
+
# inclusive range in {INTEGER_BOUNDS}. Fail-closed at Server.build is
|
|
98
|
+
# deliberate — an unbounded integer reaches the handlers — but the
|
|
99
|
+
# failure has to name the table and the property that needs the entry,
|
|
100
|
+
# not surface as a bare KeyError far from its cause.
|
|
101
|
+
def integer_bounds(name, tool_name)
|
|
102
|
+
INTEGER_BOUNDS.fetch(name.to_s) do
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"#{tool_name}: integer property '#{name}' has no entry in " \
|
|
105
|
+
"Woods::MCP::ToolContract::INTEGER_BOUNDS; add '#{name}' => [minimum, maximum] " \
|
|
106
|
+
'to that table so the argument is bounded at the wire contract'
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The SDK validates schemas, but its generic errors do not carry the
|
|
112
|
+
# stable metadata agents need and a non-object argument reaches required
|
|
113
|
+
# argument inspection before schema validation. Validate at the Index MCP
|
|
114
|
+
# boundary, then delegate valid calls to the SDK unchanged.
|
|
115
|
+
module Dispatch
|
|
116
|
+
def call_tool(request, **kwargs)
|
|
117
|
+
tool = tools[request[:name]]
|
|
118
|
+
return super unless tool
|
|
119
|
+
|
|
120
|
+
arguments = request.key?(:arguments) ? request[:arguments] : {}
|
|
121
|
+
return contract_error(request[:name], 'Arguments must be an object.') unless arguments.is_a?(Hash)
|
|
122
|
+
if request[:name] == 'codebase_retrieve' && (arguments.key?(:limit) || arguments.key?('limit'))
|
|
123
|
+
return contract_error(
|
|
124
|
+
request[:name],
|
|
125
|
+
'codebase_retrieve uses `budget` (token budget, default 8000), not `limit`.',
|
|
126
|
+
code: :unsupported_argument,
|
|
127
|
+
argument: 'limit'
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
missing = tool.input_schema_value.missing_required_arguments(arguments)
|
|
132
|
+
unless missing.empty?
|
|
133
|
+
return contract_error(
|
|
134
|
+
request[:name],
|
|
135
|
+
"Missing required arguments: #{missing.join(', ')}",
|
|
136
|
+
code: :missing_required_arguments,
|
|
137
|
+
arguments: missing
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
begin
|
|
142
|
+
tool.input_schema_value.validate_arguments(arguments)
|
|
143
|
+
rescue ::MCP::Tool::InputSchema::ValidationError => e
|
|
144
|
+
return contract_error(request[:name], e.message)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
super
|
|
148
|
+
rescue ::MCP::Server::RequestHandlerError => e
|
|
149
|
+
raise unless ToolContract.artifact_error?(e)
|
|
150
|
+
|
|
151
|
+
contract_error(
|
|
152
|
+
request[:name],
|
|
153
|
+
'An Index artifact is unavailable or malformed.',
|
|
154
|
+
code: :corrupt_artifact
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def contract_error(tool, message, code: :invalid_arguments, **meta)
|
|
161
|
+
::MCP::Tool::Response.new(
|
|
162
|
+
[{ type: 'text', text: message }],
|
|
163
|
+
error: true,
|
|
164
|
+
structured_content: { text: message },
|
|
165
|
+
meta: { error_code: code, tool: tool }.merge(meta)
|
|
166
|
+
).to_h
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|