woods 2.0.0.beta1 → 2.0.0.beta3
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 +400 -1
- data/CONTRIBUTING.md +224 -9
- data/README.md +7 -3
- data/SECURITY.md +9 -6
- data/docs/AGENT_GUIDE.md +83 -4
- data/docs/AGENT_SETUP.md +82 -1
- data/docs/BACKEND_MATRIX.md +20 -0
- data/docs/CLIENT_HOOKS.md +111 -0
- data/docs/CONFIGURATION_REFERENCE.md +233 -13
- data/docs/CONSOLE_MCP_SETUP.md +35 -5
- data/docs/DOCKER_SETUP.md +21 -2
- data/docs/EVALUATION.md +464 -1
- data/docs/EXTRACTOR_REFERENCE.md +36 -5
- data/docs/FAQ.md +11 -12
- data/docs/GETTING_STARTED.md +17 -5
- data/docs/INCREMENTAL_EXTRACTION.md +158 -2
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +15 -7
- data/docs/MCP_SERVERS.md +221 -5
- data/docs/MCP_TOOL_COOKBOOK.md +33 -18
- data/docs/NOTION_INTEGRATION.md +13 -0
- data/docs/OBSIDIAN_INTEGRATION.md +57 -9
- data/docs/PUBLISHED_INDEX.md +71 -0
- data/docs/README.md +7 -0
- data/docs/RETRIEVAL_GUIDE.md +253 -11
- data/docs/RUNTIME_TRACING.md +71 -0
- data/docs/SOURCE_FRESHNESS.md +143 -0
- data/docs/TROUBLESHOOTING.md +117 -5
- data/docs/UNBLOCKED_INTEGRATION.md +25 -0
- data/docs/UPGRADING_TO_2.md +44 -22
- data/docs/WATCH_DAEMON.md +259 -59
- data/exe/woods-agent-config +6 -0
- data/exe/woods-extract +5 -0
- data/exe/woods-hook-context +6 -0
- data/lib/generators/woods/templates/woods.rb.tt +1 -3
- data/lib/tasks/woods.rake +47 -397
- data/lib/woods/agent_configuration/applier.rb +133 -0
- data/lib/woods/agent_configuration/cli.rb +101 -0
- data/lib/woods/agent_configuration/cli_options.rb +29 -0
- data/lib/woods/agent_configuration/document.rb +105 -0
- data/lib/woods/agent_configuration/error.rb +7 -0
- data/lib/woods/agent_configuration/launcher.rb +75 -0
- data/lib/woods/agent_configuration/layout.rb +59 -0
- data/lib/woods/agent_configuration/managed_section.rb +62 -0
- data/lib/woods/agent_configuration/plan.rb +98 -0
- data/lib/woods/agent_configuration/plan_diff.rb +38 -0
- data/lib/woods/agent_configuration/planned_files.rb +61 -0
- data/lib/woods/agent_configuration/planner.rb +63 -0
- data/lib/woods/agent_configuration/planner_validation.rb +77 -0
- data/lib/woods/agent_configuration/preflight.rb +100 -0
- data/lib/woods/agent_configuration/recovery.rb +49 -0
- data/lib/woods/ast/node.rb +2 -0
- data/lib/woods/ast/parser.rb +38 -5
- data/lib/woods/atomic_file.rb +133 -3
- data/lib/woods/builder.rb +21 -5
- data/lib/woods/cache/cache_middleware.rb +28 -7
- data/lib/woods/cache/cache_store.rb +4 -5
- data/lib/woods/change_set.rb +5 -4
- data/lib/woods/console/credential_index.rb +20 -2
- data/lib/woods/console/credential_scanner.rb +14 -14
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/embedded_executor.rb +1 -1
- data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
- data/lib/woods/console/rack_middleware.rb +22 -13
- data/lib/woods/console/server.rb +18 -16
- data/lib/woods/dependency_graph.rb +65 -13
- data/lib/woods/embedding/corpus.rb +94 -0
- data/lib/woods/embedding/indexer.rb +90 -46
- data/lib/woods/embedding/openai.rb +17 -6
- data/lib/woods/evaluation/ablation_executor.rb +6 -1
- data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
- data/lib/woods/export/typed_reader.rb +56 -0
- data/lib/woods/extractor.rb +557 -228
- data/lib/woods/extractors/action_cable_extractor.rb +3 -1
- data/lib/woods/extractors/behavioral_profile.rb +9 -7
- data/lib/woods/extractors/caching_extractor.rb +3 -1
- data/lib/woods/extractors/concern_extractor.rb +64 -6
- data/lib/woods/extractors/configuration_extractor.rb +7 -3
- data/lib/woods/extractors/controller_extractor.rb +13 -4
- data/lib/woods/extractors/database_view_extractor.rb +3 -1
- data/lib/woods/extractors/decorator_extractor.rb +3 -1
- data/lib/woods/extractors/engine_extractor.rb +3 -1
- data/lib/woods/extractors/event_extractor.rb +4 -2
- data/lib/woods/extractors/factory_extractor.rb +3 -1
- data/lib/woods/extractors/graphql_extractor.rb +8 -2
- data/lib/woods/extractors/i18n_extractor.rb +3 -1
- data/lib/woods/extractors/job_extractor.rb +6 -19
- data/lib/woods/extractors/lib_extractor.rb +3 -1
- data/lib/woods/extractors/mailer_extractor.rb +20 -5
- data/lib/woods/extractors/manager_extractor.rb +3 -1
- data/lib/woods/extractors/method_parameters.rb +53 -0
- data/lib/woods/extractors/middleware_argument.rb +65 -0
- data/lib/woods/extractors/middleware_extractor.rb +9 -3
- data/lib/woods/extractors/migration_extractor.rb +3 -1
- data/lib/woods/extractors/model_extractor.rb +39 -33
- data/lib/woods/extractors/package_extractor.rb +24 -4
- data/lib/woods/extractors/phlex_extractor.rb +3 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -1
- data/lib/woods/extractors/poro_extractor.rb +3 -1
- data/lib/woods/extractors/pundit_extractor.rb +3 -1
- data/lib/woods/extractors/rails_source_extractor.rb +4 -2
- data/lib/woods/extractors/rake_task_extractor.rb +4 -2
- data/lib/woods/extractors/route_extractor.rb +3 -1
- data/lib/woods/extractors/route_helper_resolver.rb +10 -33
- data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
- data/lib/woods/extractors/serializer_extractor.rb +4 -2
- data/lib/woods/extractors/service_extractor.rb +3 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
- data/lib/woods/extractors/shared_utility_methods.rb +27 -15
- data/lib/woods/extractors/source_nesting.rb +1 -1
- data/lib/woods/extractors/state_machine_extractor.rb +3 -1
- data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
- data/lib/woods/extractors/validator_extractor.rb +3 -1
- data/lib/woods/extractors/view_component_extractor.rb +3 -1
- data/lib/woods/extractors/view_template_extractor.rb +3 -1
- data/lib/woods/flow_assembler.rb +87 -8
- data/lib/woods/flow_precomputer.rb +44 -7
- data/lib/woods/gem_mapper.rb +2 -0
- data/lib/woods/git_history.rb +116 -0
- data/lib/woods/graph_analyzer.rb +195 -63
- data/lib/woods/hooks/context_cli.rb +54 -0
- data/lib/woods/hooks/context_event.rb +88 -0
- data/lib/woods/hooks/context_hint.rb +73 -0
- data/lib/woods/hooks/context_impact.rb +77 -0
- data/lib/woods/hooks/context_output.rb +47 -0
- data/lib/woods/hooks/context_state.rb +102 -0
- data/lib/woods/hooks/refresh.rb +79 -0
- data/lib/woods/hooks/rule_projection.rb +78 -0
- data/lib/woods/input_rules.rb +19 -0
- data/lib/woods/mcp/bearer_auth.rb +20 -12
- data/lib/woods/mcp/bootstrapper.rb +62 -0
- data/lib/woods/mcp/index_reader.rb +323 -160
- data/lib/woods/mcp/initialization_guidance.rb +27 -0
- data/lib/woods/mcp/origin_guard.rb +17 -9
- data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
- data/lib/woods/mcp/search_results.rb +74 -0
- data/lib/woods/mcp/server.rb +158 -37
- data/lib/woods/mcp/tool_contract.rb +2 -0
- data/lib/woods/mcp/tool_response_renderer.rb +25 -0
- data/lib/woods/mcp/traversal_evidence.rb +113 -0
- data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
- data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
- data/lib/woods/notion/exporter.rb +56 -17
- data/lib/woods/obsidian/destination_plan.rb +98 -0
- data/lib/woods/obsidian/name_mapper.rb +19 -3
- data/lib/woods/obsidian/note_builder.rb +19 -10
- data/lib/woods/obsidian/vault_exporter.rb +88 -32
- data/lib/woods/operator/pipeline_guard.rb +18 -13
- data/lib/woods/path_dispatcher.rb +7 -1
- data/lib/woods/payload_store.rb +29 -15
- data/lib/woods/railtie.rb +3 -3
- data/lib/woods/railtie_support.rb +12 -12
- data/lib/woods/rake_helpers.rb +392 -0
- data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
- data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
- data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
- data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
- data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
- data/lib/woods/resilience/index_validator.rb +112 -23
- data/lib/woods/retrieval/context_assembler.rb +50 -15
- data/lib/woods/retrieval/lexical_assembler.rb +73 -0
- data/lib/woods/retrieval/lexical_index.rb +119 -0
- data/lib/woods/retrieval/ranker.rb +4 -2
- data/lib/woods/retrieval/scope.rb +108 -0
- data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
- data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
- data/lib/woods/retrieval/search_executor.rb +86 -27
- data/lib/woods/retrieval/source_evidence.rb +200 -0
- data/lib/woods/retriever.rb +98 -22
- data/lib/woods/ruby_analyzer/trace_enricher.rb +80 -38
- data/lib/woods/session_tracer/middleware.rb +10 -12
- data/lib/woods/session_tracer/redis_store.rb +22 -6
- data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
- data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
- data/lib/woods/session_tracer/unit_resolver.rb +63 -0
- data/lib/woods/source_inputs/consumer_errors.rb +27 -0
- data/lib/woods/source_inputs/handoff.rb +102 -0
- data/lib/woods/source_inputs/launcher.rb +157 -0
- data/lib/woods/source_inputs/manifest.rb +124 -0
- data/lib/woods/source_inputs/private_key.rb +55 -0
- data/lib/woods/source_inputs/scanner.rb +171 -0
- data/lib/woods/source_inputs/scopes.rb +71 -0
- data/lib/woods/source_inputs/session.rb +214 -0
- data/lib/woods/source_inputs/status.rb +84 -0
- data/lib/woods/source_inputs/verifier.rb +107 -0
- data/lib/woods/storage/metadata_store.rb +25 -25
- data/lib/woods/storage/pgvector.rb +29 -8
- data/lib/woods/storage/qdrant.rb +17 -7
- data/lib/woods/storage/vector_store.rb +18 -6
- data/lib/woods/tasks.rb +3 -2
- data/lib/woods/temporal/json_snapshot_store.rb +29 -8
- data/lib/woods/unblocked/exporter.rb +59 -70
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/boot_snapshot.rb +52 -0
- data/lib/woods/watch/daemon.rb +136 -28
- data/lib/woods/watch/listen_watcher.rb +4 -0
- data/lib/woods/watch/polling_watcher.rb +5 -1
- data/lib/woods/watch/status.rb +20 -15
- data/lib/woods/watch/tree_scan.rb +21 -13
- data/lib/woods/watch/watcher.rb +4 -1
- data/lib/woods.rb +135 -11
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.jq +15 -0
- data/plugin/hooks/adapters/normalize.rb +63 -0
- data/plugin/hooks/hooks.json +20 -0
- data/plugin/hooks/woods-context.sh +50 -0
- data/plugin/hooks/woods-input-rules.sh +159 -0
- data/plugin/hooks/woods-opencode.mjs +65 -0
- data/plugin/hooks/woods-post-edit.sh +2 -225
- data/plugin/hooks/woods-refresh.sh +260 -0
- data/plugin/hooks/woods-session-start.sh +47 -55
- data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
- data/plugin/skills/woods-diagnose/SKILL.md +288 -1
- data/plugin/skills/woods-investigate/SKILL.md +106 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
- data/plugin/skills/woods-setup/SKILL.md +107 -6
- metadata +84 -5
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require_relative 'plan'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module AgentConfiguration
|
|
9
|
+
module PlanDiff
|
|
10
|
+
def self.show(plan, output)
|
|
11
|
+
plan.data.fetch('changes').each do |change|
|
|
12
|
+
document = Document.new(change.fetch('path'))
|
|
13
|
+
unless document.fingerprint == change.fetch('before')
|
|
14
|
+
raise Conflict, "Changed since preview: #{document.path}; create a new plan before viewing its diff"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
output.write(render(document, Plan.after_content(change)))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.render(document, content)
|
|
22
|
+
Tempfile.create('woods-config-before') do |before|
|
|
23
|
+
Tempfile.create('woods-config-after') do |after|
|
|
24
|
+
before.write(document.content.to_s)
|
|
25
|
+
after.write(content.to_s)
|
|
26
|
+
before.flush
|
|
27
|
+
after.flush
|
|
28
|
+
diff, status = Open3.capture2('diff', '-u', '--label', document.path, '--label', document.path,
|
|
29
|
+
before.path, after.path)
|
|
30
|
+
raise Conflict, 'Cannot render plan diff' unless [0, 1].include?(status.exitstatus)
|
|
31
|
+
|
|
32
|
+
diff
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module AgentConfiguration
|
|
5
|
+
# Compute exact document replacements while tracking receipt-owned files.
|
|
6
|
+
module PlannedFiles
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def prepare_config
|
|
10
|
+
document = Document.new(@layout.config_path)
|
|
11
|
+
value = document.json
|
|
12
|
+
servers = value.fetch('mcpServers', {})
|
|
13
|
+
raise Conflict, "mcpServers must be an object: #{document.path}" unless servers.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
validate_server_ownership!(servers)
|
|
16
|
+
@created_files = @previous ? @previous.fetch('created_files').dup : []
|
|
17
|
+
track_created_file(document)
|
|
18
|
+
if @operation == 'remove'
|
|
19
|
+
servers.delete(@name)
|
|
20
|
+
else
|
|
21
|
+
servers[@name] = @entry
|
|
22
|
+
end
|
|
23
|
+
value['mcpServers'] = servers
|
|
24
|
+
@plan.add(document, config_content(document, value),
|
|
25
|
+
description: "#{@operation} owned Index MCP entry #{@name}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def config_content(document, value)
|
|
29
|
+
return if @operation == 'remove' && @created_files.include?(document.path) && value == { 'mcpServers' => {} }
|
|
30
|
+
|
|
31
|
+
document.encode_json(value)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def prepare_sections
|
|
35
|
+
previous_sections = @previous ? @previous.fetch('sections') : {}
|
|
36
|
+
requested = @instructions || previous_sections.keys
|
|
37
|
+
requested = [] if @operation == 'remove'
|
|
38
|
+
validate_instruction_selection!(requested, previous_sections)
|
|
39
|
+
@sections = {}
|
|
40
|
+
(previous_sections.keys | requested).each do |name|
|
|
41
|
+
prepare_section(name, previous_sections[name], removing: !requested.include?(name))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def prepare_section(name, previous, removing:)
|
|
46
|
+
document = Document.new(@layout.instruction_path(name))
|
|
47
|
+
content, ownership = ManagedSection.change(document.content, previous: previous, remove: removing)
|
|
48
|
+
track_created_file(document)
|
|
49
|
+
content = nil if removing && content == '' && @created_files.include?(document.path)
|
|
50
|
+
@sections[name] = ownership if ownership
|
|
51
|
+
@plan.add(document, content, description: "#{removing ? 'remove' : 'write'} owned Woods instruction section")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def track_created_file(document)
|
|
55
|
+
return unless document.content.nil? && !@created_files.include?(document.path)
|
|
56
|
+
|
|
57
|
+
@created_files << document.path
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'plan'
|
|
4
|
+
require_relative 'managed_section'
|
|
5
|
+
require_relative 'planner_validation'
|
|
6
|
+
require_relative 'planned_files'
|
|
7
|
+
|
|
8
|
+
module Woods
|
|
9
|
+
module AgentConfiguration
|
|
10
|
+
class Planner
|
|
11
|
+
include PlannerValidation
|
|
12
|
+
include PlannedFiles
|
|
13
|
+
|
|
14
|
+
TEMPLATE_VERSION = 1
|
|
15
|
+
|
|
16
|
+
def initialize(layout:, operation:, entry: nil, **options)
|
|
17
|
+
unknown = options.keys - %i[name instructions evidence intent]
|
|
18
|
+
raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" unless unknown.empty?
|
|
19
|
+
|
|
20
|
+
name = options.fetch(:name, 'woods')
|
|
21
|
+
raise Conflict, 'Supported operations: setup, update, remove' unless %w[setup update remove].include?(operation)
|
|
22
|
+
raise Conflict, 'Server name must be a simple nonempty name' unless name.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
23
|
+
|
|
24
|
+
@layout = layout
|
|
25
|
+
@operation = operation
|
|
26
|
+
@entry = entry
|
|
27
|
+
@name = name
|
|
28
|
+
@instructions = options[:instructions]
|
|
29
|
+
@evidence = options.fetch(:evidence, {})
|
|
30
|
+
@intent = options.fetch(:intent, {})
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call
|
|
34
|
+
@receipt_document = Document.new(@layout.receipt_path)
|
|
35
|
+
@previous = @receipt_document.content && @receipt_document.json
|
|
36
|
+
validate_receipt!
|
|
37
|
+
@plan = Plan.new(layout: @layout, operation: @operation, evidence: @evidence)
|
|
38
|
+
return @plan if @operation == 'remove' && @previous.nil?
|
|
39
|
+
|
|
40
|
+
prepare_config
|
|
41
|
+
prepare_sections
|
|
42
|
+
prepare_receipt
|
|
43
|
+
@plan
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def prepare_receipt
|
|
49
|
+
if @operation == 'remove'
|
|
50
|
+
@plan.add(@receipt_document, nil, description: 'remove installation receipt')
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
receipt = { 'schema_version' => 1, 'template_version' => TEMPLATE_VERSION, 'layout' => @layout.identity,
|
|
54
|
+
'server_name' => @name, 'entry' => @entry, 'sections' => @sections,
|
|
55
|
+
'created_files' => @created_files, 'intent' => @intent, 'evidence' => @evidence }
|
|
56
|
+
# A repeated setup never silently upgrades the installation's template
|
|
57
|
+
# or evidence version. The explicit update operation owns that change.
|
|
58
|
+
receipt = @previous if @operation == 'setup' && @previous && @plan.data.fetch('changes').empty?
|
|
59
|
+
@plan.add(@receipt_document, @receipt_document.encode_json(receipt), description: 'record owned installation')
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module AgentConfiguration
|
|
5
|
+
# Receipt and command validation shared by the planner's file preparations.
|
|
6
|
+
module PlannerValidation
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def validate_receipt!
|
|
10
|
+
if @previous
|
|
11
|
+
validate_receipt_identity!
|
|
12
|
+
elsif @operation == 'update'
|
|
13
|
+
raise Conflict, 'No owned installation to update; run setup first'
|
|
14
|
+
end
|
|
15
|
+
return if @operation == 'remove'
|
|
16
|
+
|
|
17
|
+
validate_entry!
|
|
18
|
+
validate_setup_compatibility! if @operation == 'setup' && @previous
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate_receipt_identity!
|
|
22
|
+
valid = @previous['schema_version'] == 1 && @previous['layout'] == @layout.identity &&
|
|
23
|
+
@previous['server_name'] == @name && @previous['sections'].is_a?(Hash) &&
|
|
24
|
+
@previous['created_files'].is_a?(Array)
|
|
25
|
+
raise Conflict, 'Installation receipt does not match this application/client/scope/server' unless valid
|
|
26
|
+
|
|
27
|
+
validate_receipt_content!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_receipt_content!
|
|
31
|
+
sections = @previous.fetch('sections')
|
|
32
|
+
valid_sections = sections.values.all? do |section|
|
|
33
|
+
section.is_a?(Hash) && section['owned_text'].is_a?(String) && section['prefix'].is_a?(String)
|
|
34
|
+
end
|
|
35
|
+
valid_files = (@previous.fetch('created_files') - @layout.allowed_paths).empty?
|
|
36
|
+
return if valid_sections && valid_files && @previous['entry'].is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
raise Conflict, 'Malformed ownership receipt; restore it before updating or removing configuration'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate_entry!
|
|
42
|
+
return if @entry.is_a?(Hash) && @entry['command'].is_a?(String) && @entry['args'].is_a?(Array)
|
|
43
|
+
|
|
44
|
+
raise Conflict, 'A preflight-validated Index MCP command is required'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_setup_compatibility!
|
|
48
|
+
if @previous['entry'] != @entry
|
|
49
|
+
raise Conflict, 'Setup differs from the owned installation; preview an explicit update'
|
|
50
|
+
end
|
|
51
|
+
return if @previous['template_version'] == self.class::TEMPLATE_VERSION
|
|
52
|
+
|
|
53
|
+
raise Conflict, 'The instruction template changed; preview an explicit update'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_server_ownership!(servers)
|
|
57
|
+
if @previous
|
|
58
|
+
return if servers[@name] == @previous.fetch('entry')
|
|
59
|
+
|
|
60
|
+
raise Conflict, 'The owned MCP entry was edited or removed; restore it or resolve ownership manually'
|
|
61
|
+
end
|
|
62
|
+
return unless servers.key?(@name)
|
|
63
|
+
|
|
64
|
+
raise Conflict, "MCP entry #{@name.inspect} already exists without a Woods receipt; choose another name"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_instruction_selection!(requested, previous_sections)
|
|
68
|
+
unless requested.is_a?(Array) && requested.uniq == requested
|
|
69
|
+
raise Conflict, 'Instruction files must be a unique list'
|
|
70
|
+
end
|
|
71
|
+
return unless @operation == 'setup' && @previous && requested.sort != previous_sections.keys.sort
|
|
72
|
+
|
|
73
|
+
raise Conflict, 'Instruction selection differs; preview an explicit update'
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
require 'bundler'
|
|
7
|
+
require_relative 'error'
|
|
8
|
+
|
|
9
|
+
module Woods
|
|
10
|
+
module AgentConfiguration
|
|
11
|
+
class Preflight
|
|
12
|
+
REQUIRED_TOOLS = %w[woods_status search lookup dependencies dependents].freeze
|
|
13
|
+
SCRIPT = <<~'RUBY'
|
|
14
|
+
require 'timeout'
|
|
15
|
+
Timeout.timeout(25) do
|
|
16
|
+
require 'json'
|
|
17
|
+
require 'woods'
|
|
18
|
+
require 'woods/mcp/server'
|
|
19
|
+
require 'woods/resilience/index_validator'
|
|
20
|
+
root = File.realpath(ARGV.fetch(1))
|
|
21
|
+
index = File.realpath(ARGV.fetch(0))
|
|
22
|
+
spec = Gem.loaded_specs.fetch('woods')
|
|
23
|
+
abort 'Installed Woods is missing woods-mcp-start' unless spec.executables.include?('woods-mcp-start')
|
|
24
|
+
reader = Woods::MCP::IndexReader.new(index)
|
|
25
|
+
manifest = reader.manifest
|
|
26
|
+
abort 'Published extraction manifest missing or malformed' unless manifest.is_a?(Hash) && manifest['counts'].is_a?(Hash)
|
|
27
|
+
report = Woods::Resilience::IndexValidator.new(index_dir: index, app_root: root).validate
|
|
28
|
+
abort report.errors.join("\n") unless report.valid?
|
|
29
|
+
tools = Woods::MCP::Server.build(index_dir: index, warmup: false).tools.keys
|
|
30
|
+
puts JSON.generate(version: Woods::VERSION, tools: tools.sort, root: root, index: index,
|
|
31
|
+
generation: reader.loaded_generation, warnings: report.warnings)
|
|
32
|
+
end
|
|
33
|
+
RUBY
|
|
34
|
+
|
|
35
|
+
def initialize(timeout: 30)
|
|
36
|
+
@timeout = timeout
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(launcher)
|
|
40
|
+
output = run(launcher)
|
|
41
|
+
evidence = JSON.parse(output)
|
|
42
|
+
raise Conflict, 'Installed Woods preflight expected a JSON object' unless evidence.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
missing = REQUIRED_TOOLS - Array(evidence['tools'])
|
|
45
|
+
raise Conflict, "Installed Woods lacks required Index capabilities: #{missing.join(', ')}" unless missing.empty?
|
|
46
|
+
raise Conflict, 'Installed Woods did not report its version' unless evidence['version'].is_a?(String)
|
|
47
|
+
|
|
48
|
+
evidence
|
|
49
|
+
rescue JSON::ParserError
|
|
50
|
+
raise Conflict, 'Installed Woods preflight did not return valid JSON; inspect application bundle and index'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def run(launcher)
|
|
56
|
+
command = launcher.probe_command(SCRIPT)
|
|
57
|
+
environment = Bundler.unbundled_env.merge(launcher.probe_environment)
|
|
58
|
+
Open3.popen3(environment, *command, chdir: launcher.root, unsetenv_others: true,
|
|
59
|
+
pgroup: true) do |stdin, out, err, wait|
|
|
60
|
+
stdin.close
|
|
61
|
+
collect(out, err, wait)
|
|
62
|
+
end
|
|
63
|
+
rescue Errno::ENOENT => e
|
|
64
|
+
raise Conflict, "Launch command unavailable: #{e.message}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def collect(stdout, stderr, wait)
|
|
68
|
+
readers = [stdout, stderr].map { |stream| Thread.new { bounded_read(stream) } }
|
|
69
|
+
Timeout.timeout(@timeout) do
|
|
70
|
+
output, errors = readers.map(&:value)
|
|
71
|
+
raise Conflict, "Installed Woods preflight failed: #{errors.strip}" unless wait.value.success?
|
|
72
|
+
|
|
73
|
+
output
|
|
74
|
+
end
|
|
75
|
+
rescue Timeout::Error
|
|
76
|
+
raise Conflict, "Installed Woods preflight exceeded #{@timeout}s; verify the selected command and container"
|
|
77
|
+
ensure
|
|
78
|
+
terminate(wait.pid)
|
|
79
|
+
readers&.each(&:kill)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def bounded_read(stream)
|
|
83
|
+
output = String.new
|
|
84
|
+
loop do
|
|
85
|
+
chunk = stream.readpartial(16_384)
|
|
86
|
+
output << chunk
|
|
87
|
+
raise Conflict, 'Installed Woods preflight output exceeded 1 MiB' if output.bytesize > 1_048_576
|
|
88
|
+
end
|
|
89
|
+
rescue EOFError
|
|
90
|
+
output
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def terminate(pid)
|
|
94
|
+
Process.kill('KILL', -pid)
|
|
95
|
+
rescue Errno::ESRCH
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module AgentConfiguration
|
|
5
|
+
# Validate every journal snapshot before restoring any managed file.
|
|
6
|
+
module Recovery
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def recover_journal(journal)
|
|
10
|
+
plan = Plan.allocate
|
|
11
|
+
plan.instance_variable_set(:@data, journal.fetch('plan'))
|
|
12
|
+
plan.validate!(@layout)
|
|
13
|
+
changes = plan.data.fetch('changes')
|
|
14
|
+
originals = journal.fetch('originals')
|
|
15
|
+
validate_originals!(originals, changes)
|
|
16
|
+
changes.each { |change| validate_recovery_snapshot!(change) }
|
|
17
|
+
originals.zip(changes).reverse_each { |original, change| restore_original(original, change) }
|
|
18
|
+
File.unlink(journal_path)
|
|
19
|
+
rescue KeyError, TypeError, ArgumentError => e
|
|
20
|
+
raise Conflict, "Invalid recovery journal; retained for manual recovery: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate_originals!(originals, changes)
|
|
24
|
+
paths = changes.map { |entry| entry['path'] }
|
|
25
|
+
return if originals.is_a?(Array) && originals.all?(Hash) && originals.map { |entry| entry['path'] } == paths
|
|
26
|
+
|
|
27
|
+
raise Conflict, "Invalid recovery journal; retain it for manual recovery: #{journal_path}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_recovery_snapshot!(change)
|
|
31
|
+
return if [change.fetch('before'), Plan.after_fingerprint(change)].include?(current(change))
|
|
32
|
+
|
|
33
|
+
raise Conflict,
|
|
34
|
+
"Concurrent edit prevents recovery of #{change.fetch('path')}; journal retained: #{journal_path}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def restore_original(original, change)
|
|
38
|
+
return if current(change) == change.fetch('before')
|
|
39
|
+
|
|
40
|
+
content = original['content'] && Base64.strict_decode64(original['content']).force_encoding(Encoding::UTF_8)
|
|
41
|
+
expected = { 'sha256' => content && Digest::SHA256.hexdigest(content),
|
|
42
|
+
'mode' => original.fetch('mode'), 'exists' => !content.nil? }
|
|
43
|
+
assert_snapshot!(expected, change.fetch('before'), original.fetch('path'))
|
|
44
|
+
assert_snapshot!(current(change), Plan.after_fingerprint(change), original.fetch('path'))
|
|
45
|
+
replace(original.fetch('path'), content, original.fetch('mode'))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/woods/ast/node.rb
CHANGED
|
@@ -26,6 +26,8 @@ module Woods
|
|
|
26
26
|
:arguments, # Array<String>: argument representations (for :send)
|
|
27
27
|
:source, # String | nil: raw source text of this node
|
|
28
28
|
:end_line, # Integer | nil: 1-based end line number (when available)
|
|
29
|
+
:start_byte, # Integer | nil: inclusive byte offset into the parsed source
|
|
30
|
+
:end_byte, # Integer | nil: exclusive byte offset into the parsed source
|
|
29
31
|
keyword_init: true
|
|
30
32
|
) do
|
|
31
33
|
# Find all descendant nodes matching a type.
|
data/lib/woods/ast/parser.rb
CHANGED
|
@@ -32,6 +32,21 @@ module Woods
|
|
|
32
32
|
raise Woods::ExtractionError, "Failed to parse source: #{e.message}"
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Validate a candidate excerpt without logging expected syntax failures
|
|
36
|
+
# (for example a heredoc body outside a method's syntactic AST range).
|
|
37
|
+
# The parser instance owns its diagnostics; no global stderr redirection.
|
|
38
|
+
def valid_fragment?(source)
|
|
39
|
+
return Prism.parse(source).success? if prism_available?
|
|
40
|
+
|
|
41
|
+
require 'parser/current' unless defined?(::Parser::CurrentRuby)
|
|
42
|
+
parser = ::Parser::CurrentRuby.default_parser
|
|
43
|
+
parser.diagnostics.consumer = nil
|
|
44
|
+
buffer = ::Parser::Source::Buffer.new('(fragment)', source: source)
|
|
45
|
+
!parser.parse(buffer).nil?
|
|
46
|
+
rescue StandardError
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
35
50
|
# Check if Prism is available.
|
|
36
51
|
#
|
|
37
52
|
# @return [Boolean]
|
|
@@ -262,7 +277,9 @@ module Woods
|
|
|
262
277
|
end_line: end_line_for_prism(prism_node),
|
|
263
278
|
method_name: prism_node.name.to_s,
|
|
264
279
|
receiver: receiver_text,
|
|
265
|
-
source: extract_prism_source_span(prism_node, source)
|
|
280
|
+
source: extract_prism_source_span(prism_node, source),
|
|
281
|
+
start_byte: prism_node.location.start_offset,
|
|
282
|
+
end_byte: prism_node.location.end_offset
|
|
266
283
|
)
|
|
267
284
|
end
|
|
268
285
|
|
|
@@ -524,12 +541,16 @@ module Woods
|
|
|
524
541
|
line: parser_node.loc.line,
|
|
525
542
|
end_line: parser_node.loc.expression.last_line,
|
|
526
543
|
method_name: parser_node.children[0].to_s,
|
|
527
|
-
source: extract_parser_source_span(parser_node, source)
|
|
544
|
+
source: extract_parser_source_span(parser_node, source),
|
|
545
|
+
start_byte: original_byte_offset(source, parser_node.loc.expression.line,
|
|
546
|
+
parser_node.loc.expression.column),
|
|
547
|
+
end_byte: original_byte_offset(source, parser_node.loc.expression.last_line,
|
|
548
|
+
parser_node.loc.expression.last_column)
|
|
528
549
|
)
|
|
529
550
|
when :defs
|
|
530
551
|
body = parser_node.children[3] ? convert_parser_node(parser_node.children[3], source) : nil
|
|
531
552
|
body_children = parser_body_children(body)
|
|
532
|
-
receiver = parser_node.children[0]
|
|
553
|
+
receiver = extract_parser_receiver_text(parser_node.children[0], source)
|
|
533
554
|
Node.new(
|
|
534
555
|
type: :defs,
|
|
535
556
|
children: body_children,
|
|
@@ -537,7 +558,11 @@ module Woods
|
|
|
537
558
|
end_line: parser_node.loc.expression.last_line,
|
|
538
559
|
method_name: parser_node.children[1].to_s,
|
|
539
560
|
receiver: receiver,
|
|
540
|
-
source: extract_parser_source_span(parser_node, source)
|
|
561
|
+
source: extract_parser_source_span(parser_node, source),
|
|
562
|
+
start_byte: original_byte_offset(source, parser_node.loc.expression.line,
|
|
563
|
+
parser_node.loc.expression.column),
|
|
564
|
+
end_byte: original_byte_offset(source, parser_node.loc.expression.last_line,
|
|
565
|
+
parser_node.loc.expression.last_column)
|
|
541
566
|
)
|
|
542
567
|
when :send
|
|
543
568
|
receiver_text = parser_node.children[0] ? extract_parser_receiver_text(parser_node.children[0], source) : nil
|
|
@@ -611,8 +636,16 @@ module Woods
|
|
|
611
636
|
end
|
|
612
637
|
end
|
|
613
638
|
|
|
639
|
+
# parser gem normalizes CRLF in its source buffer. Its line/character
|
|
640
|
+
# columns still identify the original source, but absolute positions do
|
|
641
|
+
# not. Map through original lines to preserve both CRLF and Unicode bytes.
|
|
642
|
+
def original_byte_offset(source, line, column)
|
|
643
|
+
lines = source.lines
|
|
644
|
+
lines.take(line - 1).sum(&:bytesize) + lines.fetch(line - 1)[0, column].bytesize
|
|
645
|
+
end
|
|
646
|
+
|
|
614
647
|
def parser_body_children(body_node)
|
|
615
|
-
body_node
|
|
648
|
+
body_node.is_a?(Node) && body_node.type == :begin ? body_node.children : [body_node].compact
|
|
616
649
|
end
|
|
617
650
|
|
|
618
651
|
def extract_parser_source_span(node, source)
|
data/lib/woods/atomic_file.rb
CHANGED
|
@@ -25,11 +25,30 @@ module Woods
|
|
|
25
25
|
# one: the watch daemon's +watch_status.json+, read by host-side hooks
|
|
26
26
|
# through a bind mount).
|
|
27
27
|
#
|
|
28
|
+
# +durable:+ chooses when the bytes are forced to the disk, never whether
|
|
29
|
+
# the write is atomic. Both paths go tempfile, chmod, rename, so a reader
|
|
30
|
+
# sees the old content or the new content and never a torn partial.
|
|
31
|
+
# +durable: false+ drops the two forced flushes (the temp file's own
|
|
32
|
+
# +fsync+ and the containing directory's), which is the whole cost of the
|
|
33
|
+
# write on a journalling filesystem: roughly 8.9ms per file against
|
|
34
|
+
# 0.11ms without, measured on btrfs.
|
|
35
|
+
#
|
|
36
|
+
# It is only safe for a file with no reader until something else commits
|
|
37
|
+
# it. Woods' payload files qualify: every reader resolves through
|
|
38
|
+
# +generation.json+, and that pointer is written durably after
|
|
39
|
+
# {.sync_directory_tree} has flushed the payload it names. A file whose
|
|
40
|
+
# readers do not go through the pointer (the watch daemon's status, the
|
|
41
|
+
# update check's cache, an export, an embedding checkpoint) must stay
|
|
42
|
+
# durable.
|
|
43
|
+
#
|
|
28
44
|
# @param path [String, Pathname] destination path
|
|
29
45
|
# @param content [String] file content
|
|
30
46
|
# @param mode [Integer] permissions for the written file (default 0600)
|
|
47
|
+
# @param durable [Boolean] force the bytes to disk before returning
|
|
48
|
+
# (default true). Pass false only for a file that something else makes
|
|
49
|
+
# durable before any reader can resolve it.
|
|
31
50
|
# @return [void]
|
|
32
|
-
def write(path, content, mode: 0o600)
|
|
51
|
+
def write(path, content, mode: 0o600, durable: true)
|
|
33
52
|
path = path.to_s
|
|
34
53
|
FileUtils.mkdir_p(File.dirname(path))
|
|
35
54
|
tmp = Tempfile.new('.woods-', File.dirname(path))
|
|
@@ -38,19 +57,130 @@ module Woods
|
|
|
38
57
|
tmp.binmode
|
|
39
58
|
tmp.write(content)
|
|
40
59
|
tmp.flush
|
|
41
|
-
tmp.fsync
|
|
60
|
+
tmp.fsync if durable
|
|
42
61
|
tmp.close
|
|
43
62
|
# Chmod the temp file so the destination is born with its final
|
|
44
63
|
# permissions — never observed more open or more closed in between.
|
|
45
64
|
File.chmod(mode, tmp.path)
|
|
46
65
|
File.rename(tmp.path, path)
|
|
47
|
-
fsync_directory(File.dirname(path))
|
|
66
|
+
fsync_directory(File.dirname(path)) if durable
|
|
48
67
|
rescue StandardError
|
|
49
68
|
tmp&.close
|
|
50
69
|
tmp&.unlink
|
|
51
70
|
raise
|
|
52
71
|
end
|
|
53
72
|
|
|
73
|
+
# Make every file under +directory+ durable with one filesystem flush.
|
|
74
|
+
#
|
|
75
|
+
# The counterpart to {.write}'s +durable: false+. Thousands of per-file
|
|
76
|
+
# +fsync+ calls and one +syncfs+ buy the same guarantee for a payload that
|
|
77
|
+
# is published all at once, and cost 71.3s against 1.0s for 8000 files on
|
|
78
|
+
# btrfs.
|
|
79
|
+
#
|
|
80
|
+
# Strategies, in order, first one that works wins:
|
|
81
|
+
#
|
|
82
|
+
# 1. +syncfs(2)+ on a descriptor for +directory+, through Fiddle. Linux
|
|
83
|
+
# only, and flushes exactly the one filesystem the payload is on.
|
|
84
|
+
# 2. +sync -f <dir>+ (GNU coreutils), the same call through a subprocess.
|
|
85
|
+
# 3. +sync+ with no arguments (BSD/macOS), which flushes everything
|
|
86
|
+
# mounted rather than one filesystem, but is still one call.
|
|
87
|
+
# 4. an +fsync+ on every file and directory in the tree.
|
|
88
|
+
#
|
|
89
|
+
# The last resort is what keeps the guarantee honest: the chain never
|
|
90
|
+
# silently does nothing, it only ever gets slower.
|
|
91
|
+
#
|
|
92
|
+
# Fiddle is a default gem through Ruby 3.4 and a bundled gem from 3.5, so
|
|
93
|
+
# the require lives inside a rescue and Fiddle is deliberately not in the
|
|
94
|
+
# gemspec. A host without it lands on +sync -f+.
|
|
95
|
+
#
|
|
96
|
+
# @param directory [String, Pathname] the tree to flush
|
|
97
|
+
# @return [Symbol, nil] the strategy that ran (+:syncfs+, +:sync_f+,
|
|
98
|
+
# +:sync+, +:fsync_pass+), or nil when the directory does not exist
|
|
99
|
+
def sync_directory_tree(directory)
|
|
100
|
+
directory = directory.to_s
|
|
101
|
+
return nil unless File.directory?(directory)
|
|
102
|
+
|
|
103
|
+
strategy = syncfs(directory) || sync_f(directory) || plain_sync || fsync_pass(directory)
|
|
104
|
+
log_sync_strategy(strategy, directory)
|
|
105
|
+
strategy
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Symbol, nil] +:syncfs+ when the libc call succeeded
|
|
109
|
+
def syncfs(directory)
|
|
110
|
+
call = syncfs_function
|
|
111
|
+
return nil unless call
|
|
112
|
+
|
|
113
|
+
succeeded = File.open(directory, File::RDONLY) { |dir| call.call(dir.fileno).zero? }
|
|
114
|
+
succeeded ? :syncfs : nil
|
|
115
|
+
rescue StandardError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Resolved once per process, and nil for the whole process when it cannot
|
|
120
|
+
# be. `dlopen` is not free, and a failed `require 'fiddle'` prints a
|
|
121
|
+
# bundled-gem warning on every attempt from Ruby 3.5 onward, and a publish
|
|
122
|
+
# must not emit one line of noise per generation.
|
|
123
|
+
#
|
|
124
|
+
# @return [Fiddle::Function, nil]
|
|
125
|
+
def syncfs_function
|
|
126
|
+
return @syncfs_function if defined?(@syncfs_function)
|
|
127
|
+
|
|
128
|
+
@syncfs_function = begin
|
|
129
|
+
require 'fiddle'
|
|
130
|
+
Fiddle::Function.new(Fiddle.dlopen(nil)['syncfs'], [Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
131
|
+
rescue LoadError, StandardError
|
|
132
|
+
# No Fiddle in the bundle, no libc symbol (macOS has none), or the
|
|
133
|
+
# handle would not open. `sync -f` is next in the chain.
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Symbol, nil] +:sync_f+ when `sync -f` exited 0
|
|
139
|
+
def sync_f(directory)
|
|
140
|
+
:sync_f if system('sync', '-f', directory, out: File::NULL, err: File::NULL)
|
|
141
|
+
rescue StandardError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Symbol, nil] +:sync+ when a bare `sync` exited 0
|
|
146
|
+
def plain_sync
|
|
147
|
+
:sync if system('sync', out: File::NULL, err: File::NULL)
|
|
148
|
+
rescue StandardError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [Symbol] always +:fsync_pass+; this is the floor of the chain
|
|
153
|
+
def fsync_pass(directory)
|
|
154
|
+
Dir.glob(File.join(directory, '**', '*'), File::FNM_DOTMATCH).each do |entry|
|
|
155
|
+
next if %w[. ..].include?(File.basename(entry))
|
|
156
|
+
|
|
157
|
+
fsync_path(entry)
|
|
158
|
+
end
|
|
159
|
+
fsync_path(directory)
|
|
160
|
+
# The directory entry that names this tree lives in the parent, so a
|
|
161
|
+
# freshly created (or renamed) payload directory is only durable once
|
|
162
|
+
# the parent is flushed too. The three faster strategies flush a whole
|
|
163
|
+
# filesystem and cover this for free; only the scoped pass has to say
|
|
164
|
+
# it.
|
|
165
|
+
fsync_path(File.dirname(directory))
|
|
166
|
+
:fsync_pass
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @param path [String] file or directory to flush
|
|
170
|
+
# @return [void]
|
|
171
|
+
def fsync_path(path)
|
|
172
|
+
File.open(path, File::RDONLY, &:fsync)
|
|
173
|
+
rescue Errno::EINVAL, Errno::ENOTSUP, Errno::EISDIR, Errno::ENOENT, Errno::EACCES
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# @return [void]
|
|
178
|
+
def log_sync_strategy(strategy, directory)
|
|
179
|
+
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
180
|
+
|
|
181
|
+
Rails.logger.debug { "[Woods] payload sync via #{strategy} on #{directory}" }
|
|
182
|
+
end
|
|
183
|
+
|
|
54
184
|
def fsync_directory(directory)
|
|
55
185
|
File.open(directory, File::RDONLY, &:fsync)
|
|
56
186
|
rescue Errno::EINVAL, Errno::ENOTSUP, Errno::EISDIR
|