woods 2.0.0 → 2.1.0
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 +108 -0
- data/CONTRIBUTING.md +135 -10
- data/README.md +1 -1
- data/docs/AGENT_GUIDE.md +19 -0
- data/docs/AGENT_SETUP.md +22 -2
- data/docs/BACKEND_MATRIX.md +7 -0
- data/docs/CLIENT_HOOKS.md +6 -0
- data/docs/CONFIGURATION_REFERENCE.md +133 -18
- data/docs/CONSOLE_MCP_SETUP.md +141 -12
- data/docs/EMBEDDING_MODELS.md +16 -19
- data/docs/EXTRACTOR_REFERENCE.md +219 -21
- data/docs/FAQ.md +11 -25
- data/docs/GETTING_STARTED.md +7 -1
- data/docs/INCREMENTAL_EXTRACTION.md +261 -19
- data/docs/INDEX_LAYOUT.md +5 -0
- data/docs/INTERNALS.md +9 -0
- data/docs/MCP_HTTP_TRANSPORT.md +59 -2
- data/docs/MCP_SERVERS.md +87 -8
- data/docs/MCP_TOOL_COOKBOOK.md +13 -55
- data/docs/NOTION_INTEGRATION.md +7 -1
- data/docs/PUBLISHED_INDEX.md +6 -0
- data/docs/README.md +6 -1
- data/docs/RETRIEVAL_GUIDE.md +17 -0
- data/docs/SOURCE_FRESHNESS.md +157 -5
- data/docs/TOKEN_BENCHMARK.md +10 -18
- data/docs/TROUBLESHOOTING.md +70 -14
- data/docs/UNBLOCKED_INTEGRATION.md +60 -8
- data/docs/UPGRADING_TO_2.md +184 -9
- data/docs/WATCH_DAEMON.md +97 -14
- data/exe/woods-console-mcp +2 -2
- data/exe/woods-mcp-http +16 -9
- data/lib/generators/woods/templates/woods.rb.tt +2 -1
- data/lib/tasks/woods.rake +23 -7
- data/lib/tasks/woods_checks.rake +2 -2
- data/lib/woods/agent_configuration/cli.rb +1 -1
- data/lib/woods/agent_configuration/layout.rb +16 -2
- data/lib/woods/agent_configuration/plan.rb +13 -3
- data/lib/woods/agent_configuration/planner_validation.rb +4 -2
- data/lib/woods/agent_configuration/preflight.rb +5 -3
- data/lib/woods/builder.rb +17 -57
- data/lib/woods/cache/cache_middleware.rb +68 -41
- data/lib/woods/chunking/contributor_chunks.rb +119 -0
- data/lib/woods/chunking/semantic_chunker.rb +44 -21
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/connection_manager.rb +56 -3
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/embedded_executor.rb +431 -48
- data/lib/woods/console/model_validator.rb +8 -0
- data/lib/woods/console/rack_middleware.rb +68 -11
- data/lib/woods/console/redactor.rb +24 -10
- data/lib/woods/console/safe_context.rb +44 -7
- data/lib/woods/console/sql_noise_stripper.rb +41 -12
- data/lib/woods/console/sql_table_scanner.rb +45 -34
- data/lib/woods/console/sql_validator.rb +37 -2
- data/lib/woods/dependency_graph.rb +34 -10
- data/lib/woods/embedding/fake.rb +12 -0
- data/lib/woods/embedding/indexer.rb +195 -98
- data/lib/woods/embedding/input_budget.rb +67 -0
- data/lib/woods/embedding/openai.rb +70 -20
- data/lib/woods/embedding/provider.rb +37 -25
- data/lib/woods/embedding/text_preparer.rb +76 -32
- data/lib/woods/embedding/token_counter.rb +18 -81
- data/lib/woods/embedding/vector_configuration.rb +48 -0
- data/lib/woods/extraction_identities.rb +175 -0
- data/lib/woods/extractor.rb +304 -107
- data/lib/woods/extractors/action_cable_extractor.rb +8 -3
- data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
- data/lib/woods/extractors/class_declarations.rb +121 -0
- data/lib/woods/extractors/configuration_extractor.rb +11 -3
- data/lib/woods/extractors/declaration_ancestry.rb +92 -0
- data/lib/woods/extractors/event_extractor.rb +8 -0
- data/lib/woods/extractors/graphql_extractor.rb +134 -77
- data/lib/woods/extractors/job_extractor.rb +5 -1
- data/lib/woods/extractors/lib_extractor.rb +132 -15
- data/lib/woods/extractors/mailer_extractor.rb +3 -5
- data/lib/woods/extractors/manager_extractor.rb +7 -21
- data/lib/woods/extractors/migration_declaration.rb +87 -0
- data/lib/woods/extractors/migration_extractor.rb +5 -39
- data/lib/woods/extractors/phlex_extractor.rb +6 -2
- data/lib/woods/extractors/policy_extractor.rb +9 -5
- data/lib/woods/extractors/poro_extractor.rb +112 -53
- data/lib/woods/extractors/pundit_extractor.rb +11 -6
- data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
- data/lib/woods/extractors/serializer_extractor.rb +34 -22
- data/lib/woods/extractors/shared_utility_methods.rb +18 -1
- data/lib/woods/extractors/source_nesting.rb +142 -106
- data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
- data/lib/woods/extractors/state_machine_extractor.rb +46 -40
- data/lib/woods/extractors/view_component_extractor.rb +9 -7
- data/lib/woods/flow_assembler.rb +4 -1
- data/lib/woods/generation.rb +25 -0
- data/lib/woods/hooks/context_hint.rb +7 -2
- data/lib/woods/mcp/bearer_auth.rb +1 -1
- data/lib/woods/mcp/bootstrapper.rb +33 -7
- data/lib/woods/mcp/config_resolver.rb +26 -7
- data/lib/woods/mcp/index_reader.rb +125 -24
- data/lib/woods/mcp/index_reader_pinning.rb +16 -0
- data/lib/woods/mcp/origin_guard.rb +24 -77
- data/lib/woods/mcp/origin_policy.rb +124 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
- data/lib/woods/mcp/search_results.rb +7 -1
- data/lib/woods/mcp/server.rb +24 -4
- data/lib/woods/module_reconciliation.rb +151 -0
- data/lib/woods/path_dispatcher.rb +7 -2
- data/lib/woods/railtie_support.rb +8 -0
- data/lib/woods/rake_helpers.rb +43 -11
- data/lib/woods/release.rb +1 -1
- data/lib/woods/resilience/index_validator.rb +8 -3
- data/lib/woods/resilience/retryable_provider.rb +18 -1
- data/lib/woods/resolved_config.rb +68 -8
- data/lib/woods/retrieval/context_assembler.rb +3 -3
- data/lib/woods/retrieval/lexical_assembler.rb +3 -2
- data/lib/woods/retrieval/scope.rb +18 -2
- data/lib/woods/retrieval/source_evidence.rb +14 -2
- data/lib/woods/source_contributor_validation.rb +78 -0
- data/lib/woods/source_contributors.rb +116 -0
- data/lib/woods/source_inputs/handoff.rb +37 -0
- data/lib/woods/source_inputs/launcher.rb +53 -13
- data/lib/woods/source_inputs/manifest.rb +84 -3
- data/lib/woods/source_inputs/private_key.rb +44 -12
- data/lib/woods/source_inputs/scanner.rb +98 -27
- data/lib/woods/source_inputs/scopes.rb +1 -1
- data/lib/woods/source_inputs/session.rb +147 -15
- data/lib/woods/source_inputs/stable_reader.rb +127 -0
- data/lib/woods/source_inputs/status.rb +40 -8
- data/lib/woods/source_inputs/verifier.rb +28 -5
- data/lib/woods/source_path_encoding.rb +33 -0
- data/lib/woods/source_references/cache.rb +284 -0
- data/lib/woods/source_references/collector.rb +120 -0
- data/lib/woods/source_references/extraction.rb +185 -0
- data/lib/woods/source_references/inputs.rb +134 -0
- data/lib/woods/source_references/parser_adapter.rb +134 -0
- data/lib/woods/source_references/pass.rb +152 -0
- data/lib/woods/source_references/prism_adapter.rb +116 -0
- data/lib/woods/source_references/registry.rb +178 -0
- data/lib/woods/source_references/runtime_lookup.rb +127 -0
- data/lib/woods/source_references/value_class.rb +82 -0
- data/lib/woods/storage/metadata_store.rb +4 -1
- data/lib/woods/storage/qdrant.rb +2 -2
- data/lib/woods/unblocked/client.rb +12 -7
- data/lib/woods/unblocked/document_builder.rb +4 -1
- data/lib/woods/unblocked/exporter.rb +127 -37
- data/lib/woods/unblocked/sync_manifest.rb +137 -21
- data/lib/woods/unblocked/uri_migration.rb +105 -0
- data/lib/woods/util/host_guard.rb +3 -2
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/catch_up.rb +138 -0
- data/lib/woods/watch/claim_lease.rb +150 -0
- data/lib/woods/watch/cli.rb +26 -2
- data/lib/woods/watch/daemon.rb +80 -59
- data/lib/woods/watch/installation/options.rb +1 -1
- data/lib/woods/watch/installation/receipt.rb +6 -1
- data/lib/woods/watch/managed_child.rb +1 -1
- data/lib/woods/watch/supervisor.rb +1 -1
- data/lib/woods/watch/tree_scan.rb +14 -2
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.rb +3 -2
- data/plugin/hooks/woods-input-rules.sh +4 -0
- data/plugin/hooks/woods-refresh.sh +15 -7
- data/plugin/hooks/woods-session-start.sh +60 -3
- data/plugin/skills/woods-diagnose/SKILL.md +336 -2
- data/plugin/skills/woods-investigate/SKILL.md +11 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +86 -0
- data/plugin/skills/woods-setup/SKILL.md +56 -1
- metadata +34 -5
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Unblocked
|
|
5
|
+
# Resumable, explicitly selected URI replacements. Ordinary branch changes
|
|
6
|
+
# never enter this path and never retire another branch's ownership scope.
|
|
7
|
+
class UriMigration
|
|
8
|
+
# @param manifest [SyncManifest] currently selected target scope
|
|
9
|
+
# @param client [Client] remote document operations
|
|
10
|
+
# @param from_ref [String, nil] explicit source branch, or resume pending work
|
|
11
|
+
# @param replacements [Hash{String => String}] old URI to published target URI
|
|
12
|
+
def initialize(manifest:, client:, from_ref:, replacements:)
|
|
13
|
+
@manifest = manifest
|
|
14
|
+
@client = client
|
|
15
|
+
return unless from_ref
|
|
16
|
+
|
|
17
|
+
moves = manifest.migration_sources(from_ref: from_ref).filter_map do |entry|
|
|
18
|
+
target = replacements[entry.fetch('old_uri')]
|
|
19
|
+
next if target == entry.fetch('old_uri')
|
|
20
|
+
|
|
21
|
+
entry.merge('new_uri' => target)
|
|
22
|
+
end
|
|
23
|
+
manifest.start_migration(from_ref: from_ref, moves: moves)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Array<Hash>] exact pending replacements, suitable for preview
|
|
27
|
+
def plan
|
|
28
|
+
@manifest.migration&.fetch('moves') || []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Save successful replacement receipts before deleting any old copies.
|
|
32
|
+
# This explicit migration set has its own ownership checks; the ordinary
|
|
33
|
+
# mass-delete ratio does not mistake a one-for-one migration for loss.
|
|
34
|
+
# @param current_uris [Set<String>] verified current publication membership
|
|
35
|
+
# @param errors [Array<String>] actionable refusal/failure messages
|
|
36
|
+
# @return [Integer] old copies removed
|
|
37
|
+
def cleanup(current_uris:, errors:, replacement_hashes:, remote_documents:, collection_id:)
|
|
38
|
+
return 0 if plan.empty?
|
|
39
|
+
|
|
40
|
+
@manifest.save
|
|
41
|
+
deleted = 0
|
|
42
|
+
plan.dup.each do |move|
|
|
43
|
+
remote = remote_documents[move.fetch('old_uri')]
|
|
44
|
+
if remote.nil?
|
|
45
|
+
finish_move(move)
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
reason = refusal(move, current_uris, replacement_hashes)
|
|
49
|
+
target = remote_documents[move['new_uri']]
|
|
50
|
+
unless target && target['id'] == @manifest.document_id_for(move['new_uri']) &&
|
|
51
|
+
target['collectionId'] == collection_id
|
|
52
|
+
reason ||= 'replacement remote identity is missing or changed'
|
|
53
|
+
end
|
|
54
|
+
reason ||= 'source remote identity/collection changed; review before cleanup' unless
|
|
55
|
+
remote['id'] == move['document_id'] && remote['collectionId'] == collection_id
|
|
56
|
+
if reason
|
|
57
|
+
errors << "URI migration incomplete: #{reason} (#{move.fetch('old_uri')})"
|
|
58
|
+
next
|
|
59
|
+
end
|
|
60
|
+
@client.delete_document(document_id: move.fetch('document_id'))
|
|
61
|
+
deleted += 1
|
|
62
|
+
finish_move(move)
|
|
63
|
+
rescue ApiError => e
|
|
64
|
+
if e.status == 404
|
|
65
|
+
finish_move(move)
|
|
66
|
+
else
|
|
67
|
+
errors << "URI migration cleanup failed: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
errors << "URI migration cleanup incomplete: #{e.class}: #{e.message}"
|
|
71
|
+
break
|
|
72
|
+
end
|
|
73
|
+
@manifest.finish_migration
|
|
74
|
+
deleted
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def refusal(move, current_uris, replacement_hashes)
|
|
80
|
+
unless move['owned'] == true
|
|
81
|
+
return 'legacy ownership is unverified; review the remote record before manual cleanup'
|
|
82
|
+
end
|
|
83
|
+
unless move['document_id'].is_a?(String) && !move['document_id'].empty?
|
|
84
|
+
return 'source document ID is unresolved'
|
|
85
|
+
end
|
|
86
|
+
return 'no unambiguous current replacement' unless current_uris.include?(move['new_uri'])
|
|
87
|
+
|
|
88
|
+
expected = replacement_hashes[move['new_uri']]
|
|
89
|
+
return 'replacement content has not been successfully synchronized' unless
|
|
90
|
+
expected && @manifest.unchanged?(move['new_uri'], expected)
|
|
91
|
+
|
|
92
|
+
target_id = @manifest.document_id_for(move['new_uri'])
|
|
93
|
+
return 'replacement has no persisted successful document receipt' unless target_id
|
|
94
|
+
return 'source and replacement document IDs are identical' if target_id == move['document_id']
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def finish_move(move)
|
|
100
|
+
@manifest.complete_move(move)
|
|
101
|
+
@manifest.save
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Woods
|
|
4
4
|
module Util
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# Numeric-host checks shared by {MCP::OriginPolicy} and the
|
|
6
|
+
# {Storage::VectorStore::Qdrant} URL validator. Qdrant also uses the URL
|
|
7
|
+
# canonicalization helper; HTTP authorities retain SDK matching semantics.
|
|
7
8
|
#
|
|
8
9
|
# Both components need to reject numeric IPv4 notations that `URI` and
|
|
9
10
|
# `getaddrinfo` accept but `IPAddr` does not — hex (`0x7f000001`),
|
data/lib/woods/version.rb
CHANGED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'woods/generation'
|
|
4
|
+
require 'woods/source_inputs/manifest'
|
|
5
|
+
require 'woods/source_inputs/scanner'
|
|
6
|
+
require 'woods/watch/tree_scan'
|
|
7
|
+
|
|
8
|
+
module Woods
|
|
9
|
+
module Watch
|
|
10
|
+
# Generation-bound startup evidence. Publication time is too late: an edit
|
|
11
|
+
# can land after capture, or even after final verification, before the marker.
|
|
12
|
+
class CatchUp
|
|
13
|
+
DIRTY_REASONS = %w[source_changed_during_extraction source_changed_during_read].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(root:, output_dir:, ignored:)
|
|
16
|
+
@root = root
|
|
17
|
+
@output = output_dir
|
|
18
|
+
@ignored = ignored
|
|
19
|
+
@generation = Generation.new(output_dir: output_dir)
|
|
20
|
+
@marker = @generation.current
|
|
21
|
+
@manifest = read_manifest
|
|
22
|
+
report_unavailable if @manifest&.unavailable?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A full run records the missing boundary even when incremental discovery
|
|
26
|
+
# would find no units to change (and therefore publish no new generation).
|
|
27
|
+
def rebuild?
|
|
28
|
+
@marker.number.positive? && @manifest.nil?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def paths
|
|
32
|
+
files = TreeScan.files(root: @root, ignored: @ignored)
|
|
33
|
+
return files unless @manifest
|
|
34
|
+
|
|
35
|
+
candidates = files.select { |path| recent?(path) } | dirty_paths
|
|
36
|
+
return [] if candidates.empty?
|
|
37
|
+
|
|
38
|
+
current = current_identities
|
|
39
|
+
return [] if captured_tree_matches?(current)
|
|
40
|
+
|
|
41
|
+
expected = recorded_identities
|
|
42
|
+
candidates.reject { |path| covered?(path, current, expected) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def report_unavailable
|
|
48
|
+
evidence = @manifest.data.fetch('unavailable')
|
|
49
|
+
warn "[Woods] Watch source freshness unavailable: #{evidence.fetch('reason')}; " \
|
|
50
|
+
"#{evidence.fetch('size_bytes')} bytes; limit #{evidence.fetch('limit_bytes')}. " \
|
|
51
|
+
'Continuing source-change catch-up without a freshness comparison ledger.'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def captured_tree_matches?(current)
|
|
55
|
+
@manifest.unavailable? && @current_complete &&
|
|
56
|
+
@manifest.data['capture_sha256'] == SourceInputs::Manifest.capture_digest(current)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def covered?(path, current, expected)
|
|
60
|
+
relative = path.delete_prefix("#{@root}/")
|
|
61
|
+
identities = expected[relative]
|
|
62
|
+
identities && current[relative] && identities.all? { |identity| identity == current[relative] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def read_manifest
|
|
66
|
+
payload = @generation.payload_dir(@marker)
|
|
67
|
+
return nil if @marker.payload && payload == @generation.root
|
|
68
|
+
|
|
69
|
+
manifest = load_manifest(payload)
|
|
70
|
+
data = manifest.data
|
|
71
|
+
return nil unless matching_manifest?(data)
|
|
72
|
+
|
|
73
|
+
@key = SourceInputs::PrivateKey.new(output_dir: @output)
|
|
74
|
+
@scopes = SourceInputs::Scopes.new(extra_roots: data.fetch('extra_roots'))
|
|
75
|
+
return nil unless @key.identifier == data['key_id'] && @scopes.fingerprint == data['rules']
|
|
76
|
+
|
|
77
|
+
manifest
|
|
78
|
+
rescue SourceInputs::Manifest::Invalid, SourceInputs::PrivateKey::Unavailable, SystemCallError, IOError, TypeError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def matching_manifest?(data)
|
|
83
|
+
data['root'] == @root && data['generation'] == @marker.number &&
|
|
84
|
+
data['captured_at'] && data['captured_at'] <= Time.now.to_f
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def load_manifest(payload)
|
|
88
|
+
flags = File::RDONLY | File::NONBLOCK
|
|
89
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
90
|
+
File.open(payload.join(SourceInputs::Manifest::FILE_NAME), flags) do |file|
|
|
91
|
+
unless file.stat.file? && file.stat.size <= SourceInputs::Manifest::MAX_BYTES
|
|
92
|
+
raise SourceInputs::Manifest::Invalid, 'invalid_source_manifest'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
SourceInputs::Manifest.parse(file.read(SourceInputs::Manifest::MAX_BYTES + 1))
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def recent?(path)
|
|
100
|
+
# Filesystems may record only whole seconds. Include the entire capture
|
|
101
|
+
# second; content comparison below prevents unchanged files repeating.
|
|
102
|
+
File.mtime(path).to_f >= @manifest.data.fetch('captured_at').floor
|
|
103
|
+
rescue SystemCallError
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def dirty_paths
|
|
108
|
+
@manifest.data.fetch('errors').filter_map do |error|
|
|
109
|
+
next unless DIRTY_REASONS.include?(error['reason'])
|
|
110
|
+
|
|
111
|
+
path = error['path']
|
|
112
|
+
next unless relative_path?(path)
|
|
113
|
+
|
|
114
|
+
File.join(@root, path)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def relative_path?(path)
|
|
119
|
+
path.is_a?(String) && !path.empty? && !path.start_with?('/') && !path.include?("\0") &&
|
|
120
|
+
path.split('/', -1).none? { |part| ['', '.', '..'].include?(part) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def recorded_identities
|
|
124
|
+
@manifest.expanded.each_value.with_object({}) do |paths, result|
|
|
125
|
+
paths.each { |path, identity| (result[path] ||= []) << identity }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def current_identities
|
|
130
|
+
snapshot = SourceInputs::Scanner.new(root: @root, output_dir: @output, key: @key, scopes: @scopes).call
|
|
131
|
+
@current_complete = snapshot['complete']
|
|
132
|
+
snapshot.fetch('files')
|
|
133
|
+
rescue SystemCallError, IOError
|
|
134
|
+
{}
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Watch
|
|
8
|
+
# The daemon, not its launcher, holds this descriptor for its whole run.
|
|
9
|
+
# Sidecar inodes are permanent: unlinking a locked file defeats flock.
|
|
10
|
+
class ClaimLease
|
|
11
|
+
class Unavailable < StandardError; end
|
|
12
|
+
CLAIM = 'watch_claim.json'
|
|
13
|
+
MAX_BYTES = 4096
|
|
14
|
+
|
|
15
|
+
attr_reader :token
|
|
16
|
+
|
|
17
|
+
# @param index [String] existing index directory
|
|
18
|
+
def initialize(index)
|
|
19
|
+
@index = File.realpath(index)
|
|
20
|
+
@token = SecureRandom.hex(32)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @yield while serialized with daemon claim creation and retirement
|
|
24
|
+
def coordinate
|
|
25
|
+
lock = open_lock("#{CLAIM}.lock", create: true)
|
|
26
|
+
yield
|
|
27
|
+
ensure
|
|
28
|
+
lock&.close
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param create [Boolean] only a new daemon can create the permanent lease
|
|
32
|
+
# @return [void]
|
|
33
|
+
def acquire(create: true)
|
|
34
|
+
@lease = open_lock("#{CLAIM}.lease", create: create)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Hash] identity bound to this exact locked inode
|
|
38
|
+
def fields
|
|
39
|
+
stat = @lease.stat
|
|
40
|
+
{ lease_version: 1, token: token, lease_device: stat.dev, lease_inode: stat.ino }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def close
|
|
44
|
+
@lease&.close
|
|
45
|
+
@lease = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [String] bounded regular claim bytes, without following links
|
|
49
|
+
def snapshot
|
|
50
|
+
read_claim
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Refuse legacy records and re-check the selected claim while holding both
|
|
54
|
+
# locks. Neither host names nor claim ages authorize this operation.
|
|
55
|
+
# @param token [String] exact claim token selected by the operator
|
|
56
|
+
# @return [void]
|
|
57
|
+
def recover(token:)
|
|
58
|
+
raise Unavailable, 'claim token must be 64 hexadecimal characters' unless valid_token?(token)
|
|
59
|
+
|
|
60
|
+
coordinate do
|
|
61
|
+
snapshot = read_claim
|
|
62
|
+
record = validate_claim(snapshot, token)
|
|
63
|
+
acquire(create: false)
|
|
64
|
+
verify_lease(record)
|
|
65
|
+
raise Unavailable, 'claim changed during recovery; inspect its current owner' unless read_claim == snapshot
|
|
66
|
+
|
|
67
|
+
File.unlink(File.join(@index, CLAIM))
|
|
68
|
+
end
|
|
69
|
+
ensure
|
|
70
|
+
close
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# @param name [String] fixed sidecar basename
|
|
76
|
+
# @param create [Boolean] permit initial creation
|
|
77
|
+
# @return [File] locked descriptor; never blocks on a live owner
|
|
78
|
+
def open_lock(name, create:)
|
|
79
|
+
flags = File::RDWR | File::NONBLOCK
|
|
80
|
+
flags |= File::CREAT if create
|
|
81
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
82
|
+
path = File.join(@index, name)
|
|
83
|
+
file = File.open(path, flags, 0o600) # rubocop:disable Style/FileOpen -- descriptor lifetime is ownership
|
|
84
|
+
verify_regular_path(file, path)
|
|
85
|
+
unless file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
86
|
+
label = name.end_with?('.lease') ? 'owner lease is held' : 'claim coordination is busy'
|
|
87
|
+
raise Unavailable, "#{label}; stop the owner through its supervisor and retry"
|
|
88
|
+
end
|
|
89
|
+
verify_regular_path(file, path)
|
|
90
|
+
file
|
|
91
|
+
rescue StandardError
|
|
92
|
+
file&.close
|
|
93
|
+
raise
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param file [File] opened sidecar
|
|
97
|
+
# @param path [String] unchanged regular path on disk
|
|
98
|
+
def verify_regular_path(file, path)
|
|
99
|
+
opened = file.stat
|
|
100
|
+
current = File.lstat(path)
|
|
101
|
+
return if opened.file? && current.file? && [opened.dev, opened.ino] == [current.dev, current.ino]
|
|
102
|
+
|
|
103
|
+
raise Unavailable, 'claim sidecar must be an unchanged regular file; do not replace lock files'
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def read_claim
|
|
107
|
+
path = File.join(@index, CLAIM)
|
|
108
|
+
flags = File::RDONLY | File::NONBLOCK
|
|
109
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
110
|
+
File.open(path, flags) do |file|
|
|
111
|
+
verify_regular_path(file, path)
|
|
112
|
+
bytes = file.read(MAX_BYTES + 1)
|
|
113
|
+
raise Unavailable, 'claim exceeds the recovery size limit' if bytes.bytesize > MAX_BYTES
|
|
114
|
+
|
|
115
|
+
bytes
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_claim(snapshot, token)
|
|
120
|
+
record = JSON.parse(snapshot)
|
|
121
|
+
unless valid_record?(record)
|
|
122
|
+
raise Unavailable, 'legacy or unverifiable claim; use the documented legacy recovery procedure'
|
|
123
|
+
end
|
|
124
|
+
unless record['token'] == token
|
|
125
|
+
raise Unavailable, 'claim token changed; inspect the current owner before retrying'
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
record
|
|
129
|
+
rescue JSON::ParserError
|
|
130
|
+
raise Unavailable, 'legacy or unverifiable claim; use the documented legacy recovery procedure'
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def valid_record?(record)
|
|
134
|
+
record.is_a?(Hash) && record['lease_version'] == 1 && valid_token?(record['token']) &&
|
|
135
|
+
record['pid'].is_a?(Integer) && record['pid'].positive? && record['host'].is_a?(String)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def verify_lease(record)
|
|
139
|
+
stat = @lease.stat
|
|
140
|
+
return if record['lease_device'] == stat.dev && record['lease_inode'] == stat.ino
|
|
141
|
+
|
|
142
|
+
raise Unavailable, 'owner lease inode changed; ownership cannot be verified'
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def valid_token?(value)
|
|
146
|
+
value.is_a?(String) && value.match?(/\A[a-f0-9]{64}\z/)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
data/lib/woods/watch/cli.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'optparse'
|
|
4
4
|
require_relative 'supervisor'
|
|
5
|
+
require_relative 'claim_lease'
|
|
5
6
|
|
|
6
7
|
module Woods
|
|
7
8
|
module Watch
|
|
@@ -17,12 +18,13 @@ module Woods
|
|
|
17
18
|
def run(argv)
|
|
18
19
|
options, command = parse(argv.dup)
|
|
19
20
|
return 0 if @help
|
|
21
|
+
return recover_claim(options, command) if @recovery_index || @claim_token
|
|
20
22
|
|
|
21
23
|
validate_platform!
|
|
22
24
|
validate_command!(command, options[:root])
|
|
23
25
|
supervisor = Supervisor.new(command: command, logger: @output, **options)
|
|
24
26
|
with_signals(supervisor) { supervisor.run }
|
|
25
|
-
rescue OptionParser::ParseError, ArgumentError, SystemCallError => e
|
|
27
|
+
rescue OptionParser::ParseError, ArgumentError, SystemCallError, ClaimLease::Unavailable => e
|
|
26
28
|
@output.puts("[woods-watch] #{e.message}")
|
|
27
29
|
2
|
|
28
30
|
end
|
|
@@ -31,6 +33,7 @@ module Woods
|
|
|
31
33
|
|
|
32
34
|
def parse(argv)
|
|
33
35
|
@help = false
|
|
36
|
+
@recovery_index = @claim_token = nil
|
|
34
37
|
options = { root: Dir.pwd, boot_timeout: 300, shutdown_timeout: 10 }
|
|
35
38
|
parser = OptionParser.new do |flags|
|
|
36
39
|
flags.banner = 'Usage: woods-watch [options] -- [bin/rails woods:watch]'
|
|
@@ -43,11 +46,32 @@ module Woods
|
|
|
43
46
|
flags.on('--shutdown-timeout SECONDS', 'Graceful shutdown limit (default: 10)') do |value|
|
|
44
47
|
options[:shutdown_timeout] = positive_seconds(value)
|
|
45
48
|
end
|
|
49
|
+
recovery_options(flags)
|
|
46
50
|
flags.on('-h', '--help', 'Show help without booting Rails') { @help = true }
|
|
47
51
|
end
|
|
48
52
|
parser.order!(argv)
|
|
49
53
|
@output.puts(parser) if @help
|
|
50
|
-
[options, argv.empty? ? ['bin/rails', 'woods:watch'] : argv]
|
|
54
|
+
[options, argv.empty? && !@recovery_index && !@claim_token ? ['bin/rails', 'woods:watch'] : argv]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def recovery_options(flags)
|
|
58
|
+
flags.on('--recover-claim INDEX', 'Recover an abandoned managed claim; requires --claim-token') do |path|
|
|
59
|
+
@recovery_index = path
|
|
60
|
+
end
|
|
61
|
+
flags.on('--claim-token TOKEN', 'Exact claim token; never overrides a live owner lease') do |token|
|
|
62
|
+
@claim_token = token
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def recover_claim(options, command)
|
|
67
|
+
unless @recovery_index && @claim_token && command.empty?
|
|
68
|
+
raise ArgumentError, 'use --recover-claim INDEX --claim-token TOKEN without a child command'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
index = File.expand_path(@recovery_index, options[:root])
|
|
72
|
+
ClaimLease.new(index).recover(token: @claim_token)
|
|
73
|
+
@output.puts("[woods-watch] recovered the selected abandoned claim at #{index}; restart its normal owner")
|
|
74
|
+
0
|
|
51
75
|
end
|
|
52
76
|
|
|
53
77
|
def positive_seconds(value)
|