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
|
@@ -70,31 +70,53 @@ module Woods
|
|
|
70
70
|
#
|
|
71
71
|
# @return [Hash] { exported:, indexes:, swept:, skipped:, errors: }
|
|
72
72
|
def export_all
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
with_pinned_index do
|
|
74
|
+
graph = @reader.raw_graph_data || {}
|
|
75
|
+
@nodes = graph['nodes'] || {}
|
|
76
|
+
@pagerank = graph['pagerank'] || {}
|
|
77
|
+
analysis = safe_graph_analysis
|
|
78
|
+
owned = vault_owned_at_start?
|
|
79
|
+
|
|
80
|
+
emitted, skipped = partition_emitted
|
|
81
|
+
mapper = build_mapper(emitted)
|
|
82
|
+
edge_map = build_edge_map(emitted.to_set, graph)
|
|
83
|
+
builder = build_note_builder(mapper, analysis)
|
|
84
|
+
|
|
85
|
+
written = Set.new
|
|
86
|
+
errors = []
|
|
87
|
+
exported = write_notes(emitted, mapper, edge_map, builder, written, errors)
|
|
88
|
+
indexes = write_indexes(emitted, mapper, written)
|
|
89
|
+
write_sidecar(emitted, mapper, graph, analysis, written)
|
|
90
|
+
write_owned_assets(emitted) if owned
|
|
91
|
+
|
|
92
|
+
swept = owned && errors.empty? ? sweep(written) : 0
|
|
93
|
+
warn_foreign unless owned
|
|
94
|
+
{ exported: exported, indexes: indexes, swept: swept, skipped: skipped, errors: cap_errors(errors) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
78
97
|
|
|
79
|
-
|
|
80
|
-
mapper = build_mapper(emitted)
|
|
81
|
-
edge_map = build_edge_map(emitted.to_set, graph)
|
|
82
|
-
builder = build_note_builder(mapper, analysis)
|
|
98
|
+
private
|
|
83
99
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
100
|
+
# Run the export against one index generation.
|
|
101
|
+
#
|
|
102
|
+
# Every public IndexReader accessor self-refreshes when the published
|
|
103
|
+
# generation moves, and the reader assigns pinning responsibility to
|
|
104
|
+
# direct callers. Unpinned, `raw_graph_data` and the per-unit reads can
|
|
105
|
+
# straddle two generations (EXP-5) — which the "byte-identical across
|
|
106
|
+
# runs" contract cannot survive, and which computes the sweep set against
|
|
107
|
+
# a mixture.
|
|
108
|
+
#
|
|
109
|
+
# Guarded by +respond_to?+: injected readers (specs, embedders) need not
|
|
110
|
+
# implement pinning.
|
|
111
|
+
#
|
|
112
|
+
# @yield the export body
|
|
113
|
+
# @return [Object] the block's value
|
|
114
|
+
def with_pinned_index(&block)
|
|
115
|
+
return yield unless @reader.respond_to?(:with_pinned_generation)
|
|
90
116
|
|
|
91
|
-
|
|
92
|
-
warn_foreign unless owned
|
|
93
|
-
{ exported: exported, indexes: indexes, swept: swept, skipped: skipped, errors: cap_errors(errors) }
|
|
117
|
+
@reader.with_pinned_generation(&block)
|
|
94
118
|
end
|
|
95
119
|
|
|
96
|
-
private
|
|
97
|
-
|
|
98
120
|
# ── Selection / mapping ──────────────────────────────────────────
|
|
99
121
|
|
|
100
122
|
# The emitted set = graph nodes of an exportable type that have a loadable
|
|
@@ -145,7 +167,17 @@ module Woods
|
|
|
145
167
|
|
|
146
168
|
def dir_for(type)
|
|
147
169
|
require 'woods/mcp/index_reader'
|
|
148
|
-
Woods::MCP::IndexReader::TYPE_TO_DIR[type] || "#{type}s"
|
|
170
|
+
Woods::MCP::IndexReader::TYPE_TO_DIR[type] || sanitize_dir_segment("#{type}s")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Same character policy as NameMapper's basename sanitizer: an unknown
|
|
174
|
+
# graph node type reaches here verbatim (it isn't a fixed TYPE_TO_DIR
|
|
175
|
+
# value), so it gets the same treatment before being used as a path
|
|
176
|
+
# segment — otherwise a type of "../../escape" would flow straight into
|
|
177
|
+
# the vault-relative path NameMapper builds.
|
|
178
|
+
def sanitize_dir_segment(str)
|
|
179
|
+
out = str.to_s.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
|
|
180
|
+
out.empty? ? 'unit' : out
|
|
149
181
|
end
|
|
150
182
|
|
|
151
183
|
def build_note_builder(mapper, analysis)
|
|
@@ -187,7 +219,7 @@ module Woods
|
|
|
187
219
|
next if note.nil?
|
|
188
220
|
|
|
189
221
|
rel = mapper.path_for(id)
|
|
190
|
-
|
|
222
|
+
safe_write(@vault.join(rel), note)
|
|
191
223
|
written << rel
|
|
192
224
|
exported += 1
|
|
193
225
|
rescue StandardError => e
|
|
@@ -245,13 +277,13 @@ module Woods
|
|
|
245
277
|
|
|
246
278
|
def write_owned_assets(emitted)
|
|
247
279
|
if can_write_obsidian_config?
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
280
|
+
safe_write(@vault.join('.obsidian', 'app.json'), VaultAssets.app_json)
|
|
281
|
+
safe_write(@vault.join('.obsidian', 'types.json'), VaultAssets.types_json)
|
|
282
|
+
safe_write(@vault.join('.obsidian', 'graph.json'), VaultAssets.graph_json(present_types(emitted)))
|
|
283
|
+
safe_write(@vault.join('Units.base'), VaultAssets.units_base)
|
|
252
284
|
end
|
|
253
|
-
|
|
254
|
-
|
|
285
|
+
safe_write(@vault.join(SENTINEL),
|
|
286
|
+
"woods-managed vault — regenerated by `rake woods:obsidian`. Safe to delete.\n")
|
|
255
287
|
end
|
|
256
288
|
|
|
257
289
|
def can_write_obsidian_config?
|
|
@@ -284,12 +316,19 @@ module Woods
|
|
|
284
316
|
end
|
|
285
317
|
end
|
|
286
318
|
|
|
319
|
+
# Every managed note in the vault, vault-relative.
|
|
320
|
+
#
|
|
321
|
+
# `base:` keeps the vault's own path out of the pattern: "[", "]", "{",
|
|
322
|
+
# "}", "*" and "?" in a folder name (`my [work] vault` is an ordinary
|
|
323
|
+
# human folder name) are glob syntax, and interpolating the path matched
|
|
324
|
+
# nothing — the sweep saw zero managed notes and deleted nothing,
|
|
325
|
+
# silently, forever (EXP-6).
|
|
326
|
+
#
|
|
327
|
+
# @return [Array<String>] paths relative to the vault root
|
|
287
328
|
def managed_notes
|
|
288
|
-
|
|
289
|
-
next unless managed_marker?(abs)
|
|
329
|
+
return [] unless @vault.directory?
|
|
290
330
|
|
|
291
|
-
|
|
292
|
-
end
|
|
331
|
+
Dir.glob(File.join('**', '*.md'), base: @vault.to_s).select { |rel| managed_marker?(@vault.join(rel)) }
|
|
293
332
|
end
|
|
294
333
|
|
|
295
334
|
# Only our notes carry this frontmatter flag. Reads just the frontmatter
|
|
@@ -339,13 +378,41 @@ module Woods
|
|
|
339
378
|
end
|
|
340
379
|
|
|
341
380
|
def safe_inside_vault?(path)
|
|
342
|
-
root = @vault
|
|
343
|
-
target = path
|
|
381
|
+
root = canonical(@vault)
|
|
382
|
+
target = canonical(path)
|
|
344
383
|
target == root || target.start_with?(root + File::SEPARATOR)
|
|
345
384
|
rescue StandardError
|
|
346
385
|
false
|
|
347
386
|
end
|
|
348
387
|
|
|
388
|
+
# realpath requires the target to exist, but a write target (and, on the
|
|
389
|
+
# very first write of a fresh export, the vault root itself) usually
|
|
390
|
+
# doesn't yet. Resolve symlinks on the nearest existing ancestor only,
|
|
391
|
+
# then reattach the not-yet-created suffix literally. Both root and
|
|
392
|
+
# target must go through this same resolution — comparing a realpath'd
|
|
393
|
+
# root against an expand_path'd target falsely disagrees the moment an
|
|
394
|
+
# ancestor (e.g. macOS's /tmp -> /private/tmp) is itself a symlink.
|
|
395
|
+
def canonical(path)
|
|
396
|
+
existing = Pathname.new(path.to_s)
|
|
397
|
+
suffix = []
|
|
398
|
+
until existing.exist? || existing.root?
|
|
399
|
+
suffix.unshift(existing.basename.to_s)
|
|
400
|
+
existing = existing.dirname
|
|
401
|
+
end
|
|
402
|
+
base = existing.exist? ? existing.realpath.to_s : existing.to_s
|
|
403
|
+
suffix.empty? ? base : File.join(base, *suffix)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# The single write chokepoint: every AtomicFile.write in this class goes
|
|
407
|
+
# through here so a future regression upstream (a bad dir_for fallback,
|
|
408
|
+
# a mapper bug, a crafted rel path) fails closed with a typed error
|
|
409
|
+
# instead of writing outside the vault.
|
|
410
|
+
def safe_write(path, content)
|
|
411
|
+
raise PathTraversalError, "refusing to write outside the vault: #{path}" unless safe_inside_vault?(path)
|
|
412
|
+
|
|
413
|
+
AtomicFile.write(path, content)
|
|
414
|
+
end
|
|
415
|
+
|
|
349
416
|
# ── Ownership ────────────────────────────────────────────────────
|
|
350
417
|
|
|
351
418
|
def vault_owned_at_start?
|
|
@@ -363,12 +430,12 @@ module Woods
|
|
|
363
430
|
# ── Helpers ──────────────────────────────────────────────────────
|
|
364
431
|
|
|
365
432
|
def write_managed(written, rel, content)
|
|
366
|
-
|
|
433
|
+
safe_write(@vault.join(rel), content)
|
|
367
434
|
written << rel
|
|
368
435
|
end
|
|
369
436
|
|
|
370
437
|
def write_json(written, rel, data)
|
|
371
|
-
|
|
438
|
+
safe_write(@vault.join(rel), "#{JSON.pretty_generate(data)}\n")
|
|
372
439
|
written << rel
|
|
373
440
|
end
|
|
374
441
|
|
|
@@ -19,6 +19,8 @@ module Woods
|
|
|
19
19
|
# end
|
|
20
20
|
#
|
|
21
21
|
class PipelineGuard
|
|
22
|
+
SUPPORTED_OPERATIONS = %w[extraction embedding].freeze
|
|
23
|
+
|
|
22
24
|
# @param state_dir [String] Directory for persisting state
|
|
23
25
|
# @param cooldown [Integer] Minimum seconds between runs
|
|
24
26
|
def initialize(state_dir:, cooldown: 300)
|
|
@@ -29,13 +31,44 @@ module Woods
|
|
|
29
31
|
|
|
30
32
|
# Check if an operation is allowed (cooldown elapsed).
|
|
31
33
|
#
|
|
34
|
+
# Fails closed on state this process cannot trust: :missing is the
|
|
35
|
+
# legitimate "never run" case and allows, but :corrupt and
|
|
36
|
+
# :permission_denied used to be indistinguishable from :missing —
|
|
37
|
+
# both parsed to `{}` (or crashed uncaught) and let the operation
|
|
38
|
+
# through, silently defeating the cooldown on exactly the state that
|
|
39
|
+
# means it can no longer be verified.
|
|
40
|
+
#
|
|
32
41
|
# @param operation [Symbol, String] Operation name
|
|
33
42
|
# @return [Boolean]
|
|
34
43
|
def allow?(operation)
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
case state_status
|
|
45
|
+
when :corrupt, :permission_denied
|
|
46
|
+
false
|
|
47
|
+
else
|
|
48
|
+
last = last_run(operation)
|
|
49
|
+
return true if last.nil?
|
|
37
50
|
|
|
38
|
-
|
|
51
|
+
(Time.now - last) >= @cooldown
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Diagnose the on-disk cooldown state without side effects, distinguishing
|
|
56
|
+
# the three states {#allow?} used to collapse into one silent outcome —
|
|
57
|
+
# public callers (the MCP pipeline tools) need to tell a fresh install
|
|
58
|
+
# apart from a state file that needs operator attention.
|
|
59
|
+
#
|
|
60
|
+
# @return [Symbol] `:ok`, `:missing`, `:corrupt`, or `:permission_denied`
|
|
61
|
+
def state_status
|
|
62
|
+
return :missing unless File.exist?(@state_path)
|
|
63
|
+
|
|
64
|
+
parsed = JSON.parse(File.read(@state_path))
|
|
65
|
+
parsed.is_a?(Hash) ? :ok : :corrupt
|
|
66
|
+
rescue Errno::EACCES
|
|
67
|
+
:permission_denied
|
|
68
|
+
rescue JSON::ParserError
|
|
69
|
+
:corrupt
|
|
70
|
+
rescue Errno::ENOENT
|
|
71
|
+
:missing
|
|
39
72
|
end
|
|
40
73
|
|
|
41
74
|
# Record that an operation has just run.
|
|
@@ -43,23 +76,31 @@ module Woods
|
|
|
43
76
|
# @param operation [Symbol, String] Operation name
|
|
44
77
|
# @return [void]
|
|
45
78
|
def record!(operation)
|
|
46
|
-
|
|
47
|
-
File.open(@state_path, File::RDWR | File::CREAT) do |f|
|
|
48
|
-
f.flock(File::LOCK_EX)
|
|
49
|
-
content = f.read
|
|
50
|
-
state = if content.empty?
|
|
51
|
-
{}
|
|
52
|
-
else
|
|
53
|
-
begin
|
|
54
|
-
JSON.parse(content)
|
|
55
|
-
rescue StandardError
|
|
56
|
-
{}
|
|
57
|
-
end
|
|
58
|
-
end
|
|
79
|
+
with_locked_state do |state|
|
|
59
80
|
state[operation.to_s] = Time.now.iso8601
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Reset one supported operation or all supported operations atomically.
|
|
87
|
+
#
|
|
88
|
+
# @param operation [Symbol, String] `:extraction`, `:embedding`, or `:all`
|
|
89
|
+
# @return [Boolean] true when at least one cooldown was removed
|
|
90
|
+
# @raise [ArgumentError] when the operation is unsupported
|
|
91
|
+
def reset!(operation)
|
|
92
|
+
operations = reset_operations(operation)
|
|
93
|
+
return false unless requested_state_present?(operations)
|
|
94
|
+
|
|
95
|
+
with_existing_locked_state do |state|
|
|
96
|
+
changed = false
|
|
97
|
+
operations.each do |key|
|
|
98
|
+
next unless state.key?(key)
|
|
99
|
+
|
|
100
|
+
state.delete(key)
|
|
101
|
+
changed = true
|
|
102
|
+
end
|
|
103
|
+
changed
|
|
63
104
|
end
|
|
64
105
|
end
|
|
65
106
|
|
|
@@ -79,12 +120,68 @@ module Woods
|
|
|
79
120
|
|
|
80
121
|
private
|
|
81
122
|
|
|
123
|
+
def with_locked_state
|
|
124
|
+
FileUtils.mkdir_p(@state_dir)
|
|
125
|
+
File.open(@state_path, File::RDWR | File::CREAT) do |file|
|
|
126
|
+
file.flock(File::LOCK_EX)
|
|
127
|
+
state = parse_state(file.read)
|
|
128
|
+
changed = yield(state)
|
|
129
|
+
if changed
|
|
130
|
+
file.rewind
|
|
131
|
+
file.write(JSON.generate(state))
|
|
132
|
+
file.truncate(file.pos)
|
|
133
|
+
end
|
|
134
|
+
changed
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse_state(content)
|
|
139
|
+
state = content.empty? ? {} : JSON.parse(content)
|
|
140
|
+
state.is_a?(Hash) ? state : {}
|
|
141
|
+
rescue JSON::ParserError
|
|
142
|
+
{}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def requested_state_present?(operations)
|
|
146
|
+
File.open(@state_path, File::RDONLY) do |file|
|
|
147
|
+
file.flock(File::LOCK_SH)
|
|
148
|
+
state = parse_state(file.read)
|
|
149
|
+
operations.any? { |key| state.key?(key) }
|
|
150
|
+
end
|
|
151
|
+
rescue Errno::ENOENT
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def with_existing_locked_state
|
|
156
|
+
File.open(@state_path, File::RDWR) do |file|
|
|
157
|
+
file.flock(File::LOCK_EX)
|
|
158
|
+
state = parse_state(file.read)
|
|
159
|
+
changed = yield(state)
|
|
160
|
+
if changed
|
|
161
|
+
file.rewind
|
|
162
|
+
file.write(JSON.generate(state))
|
|
163
|
+
file.truncate(file.pos)
|
|
164
|
+
end
|
|
165
|
+
changed
|
|
166
|
+
end
|
|
167
|
+
rescue Errno::ENOENT
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def reset_operations(operation)
|
|
172
|
+
key = operation.to_s
|
|
173
|
+
return SUPPORTED_OPERATIONS if key == 'all'
|
|
174
|
+
return [key] if SUPPORTED_OPERATIONS.include?(key)
|
|
175
|
+
|
|
176
|
+
raise ArgumentError, "Unsupported pipeline operation: #{operation}"
|
|
177
|
+
end
|
|
178
|
+
|
|
82
179
|
# @return [Hash]
|
|
83
180
|
def read_state
|
|
84
181
|
return {} unless File.exist?(@state_path)
|
|
85
182
|
|
|
86
|
-
|
|
87
|
-
rescue
|
|
183
|
+
parse_state(File.read(@state_path))
|
|
184
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
88
185
|
{}
|
|
89
186
|
end
|
|
90
187
|
end
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'time'
|
|
5
5
|
|
|
6
|
+
require_relative '../atomic_file'
|
|
7
|
+
require_relative '../generation'
|
|
8
|
+
|
|
6
9
|
module Woods
|
|
7
10
|
module Operator
|
|
8
11
|
# Reports pipeline status by reading extraction output metadata.
|
|
@@ -43,13 +46,27 @@ module Woods
|
|
|
43
46
|
|
|
44
47
|
private
|
|
45
48
|
|
|
49
|
+
# Read manifest.json via AtomicFile.read (a bare File.read tags the bytes
|
|
50
|
+
# with the process's default external encoding — US-ASCII under LANG=C —
|
|
51
|
+
# so a non-ASCII branch name raised EncodingError out of JSON.parse). An
|
|
52
|
+
# unparseable or unreadable manifest degrades to nil, the same as a
|
|
53
|
+
# missing one: the report becomes :not_extracted rather than an error.
|
|
54
|
+
#
|
|
55
|
+
# Resolved through {Woods::Generation#payload_dir}: a payload-born
|
|
56
|
+
# index keeps +manifest.json+ only under the directory the published
|
|
57
|
+
# generation names, never at +@output_dir+ directly, so reading the
|
|
58
|
+
# root path unconditionally reported :not_extracted against a fresh
|
|
59
|
+
# extraction. Resolves to +@output_dir+ itself for a flat (pre-payload)
|
|
60
|
+
# index, so this is a no-op for every index that predates payloads.
|
|
61
|
+
#
|
|
46
62
|
# @return [Hash, nil]
|
|
47
63
|
def read_manifest
|
|
48
|
-
|
|
64
|
+
generation = Woods::Generation.new(output_dir: @output_dir)
|
|
65
|
+
path = generation.payload_dir(generation.current).join('manifest.json')
|
|
49
66
|
return nil unless File.exist?(path)
|
|
50
67
|
|
|
51
|
-
JSON.parse(
|
|
52
|
-
rescue JSON::ParserError
|
|
68
|
+
JSON.parse(AtomicFile.read(path))
|
|
69
|
+
rescue JSON::ParserError, EncodingError, SystemCallError
|
|
53
70
|
nil
|
|
54
71
|
end
|
|
55
72
|
|