woods 1.6.1 → 2.0.0.beta2

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