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.
Files changed (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/CONTRIBUTING.md +135 -10
  4. data/README.md +1 -1
  5. data/docs/AGENT_GUIDE.md +19 -0
  6. data/docs/AGENT_SETUP.md +22 -2
  7. data/docs/BACKEND_MATRIX.md +7 -0
  8. data/docs/CLIENT_HOOKS.md +6 -0
  9. data/docs/CONFIGURATION_REFERENCE.md +133 -18
  10. data/docs/CONSOLE_MCP_SETUP.md +141 -12
  11. data/docs/EMBEDDING_MODELS.md +16 -19
  12. data/docs/EXTRACTOR_REFERENCE.md +219 -21
  13. data/docs/FAQ.md +11 -25
  14. data/docs/GETTING_STARTED.md +7 -1
  15. data/docs/INCREMENTAL_EXTRACTION.md +261 -19
  16. data/docs/INDEX_LAYOUT.md +5 -0
  17. data/docs/INTERNALS.md +9 -0
  18. data/docs/MCP_HTTP_TRANSPORT.md +59 -2
  19. data/docs/MCP_SERVERS.md +87 -8
  20. data/docs/MCP_TOOL_COOKBOOK.md +13 -55
  21. data/docs/NOTION_INTEGRATION.md +7 -1
  22. data/docs/PUBLISHED_INDEX.md +6 -0
  23. data/docs/README.md +6 -1
  24. data/docs/RETRIEVAL_GUIDE.md +17 -0
  25. data/docs/SOURCE_FRESHNESS.md +157 -5
  26. data/docs/TOKEN_BENCHMARK.md +10 -18
  27. data/docs/TROUBLESHOOTING.md +70 -14
  28. data/docs/UNBLOCKED_INTEGRATION.md +60 -8
  29. data/docs/UPGRADING_TO_2.md +184 -9
  30. data/docs/WATCH_DAEMON.md +97 -14
  31. data/exe/woods-console-mcp +2 -2
  32. data/exe/woods-mcp-http +16 -9
  33. data/lib/generators/woods/templates/woods.rb.tt +2 -1
  34. data/lib/tasks/woods.rake +23 -7
  35. data/lib/tasks/woods_checks.rake +2 -2
  36. data/lib/woods/agent_configuration/cli.rb +1 -1
  37. data/lib/woods/agent_configuration/layout.rb +16 -2
  38. data/lib/woods/agent_configuration/plan.rb +13 -3
  39. data/lib/woods/agent_configuration/planner_validation.rb +4 -2
  40. data/lib/woods/agent_configuration/preflight.rb +5 -3
  41. data/lib/woods/builder.rb +17 -57
  42. data/lib/woods/cache/cache_middleware.rb +68 -41
  43. data/lib/woods/chunking/contributor_chunks.rb +119 -0
  44. data/lib/woods/chunking/semantic_chunker.rb +44 -21
  45. data/lib/woods/console/adapter_family.rb +39 -0
  46. data/lib/woods/console/connection_manager.rb +56 -3
  47. data/lib/woods/console/credential_index.rb +33 -3
  48. data/lib/woods/console/embedded_executor.rb +431 -48
  49. data/lib/woods/console/model_validator.rb +8 -0
  50. data/lib/woods/console/rack_middleware.rb +68 -11
  51. data/lib/woods/console/redactor.rb +24 -10
  52. data/lib/woods/console/safe_context.rb +44 -7
  53. data/lib/woods/console/sql_noise_stripper.rb +41 -12
  54. data/lib/woods/console/sql_table_scanner.rb +45 -34
  55. data/lib/woods/console/sql_validator.rb +37 -2
  56. data/lib/woods/dependency_graph.rb +34 -10
  57. data/lib/woods/embedding/fake.rb +12 -0
  58. data/lib/woods/embedding/indexer.rb +195 -98
  59. data/lib/woods/embedding/input_budget.rb +67 -0
  60. data/lib/woods/embedding/openai.rb +70 -20
  61. data/lib/woods/embedding/provider.rb +37 -25
  62. data/lib/woods/embedding/text_preparer.rb +76 -32
  63. data/lib/woods/embedding/token_counter.rb +18 -81
  64. data/lib/woods/embedding/vector_configuration.rb +48 -0
  65. data/lib/woods/extraction_identities.rb +175 -0
  66. data/lib/woods/extractor.rb +304 -107
  67. data/lib/woods/extractors/action_cable_extractor.rb +8 -3
  68. data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
  69. data/lib/woods/extractors/class_declarations.rb +121 -0
  70. data/lib/woods/extractors/configuration_extractor.rb +11 -3
  71. data/lib/woods/extractors/declaration_ancestry.rb +92 -0
  72. data/lib/woods/extractors/event_extractor.rb +8 -0
  73. data/lib/woods/extractors/graphql_extractor.rb +134 -77
  74. data/lib/woods/extractors/job_extractor.rb +5 -1
  75. data/lib/woods/extractors/lib_extractor.rb +132 -15
  76. data/lib/woods/extractors/mailer_extractor.rb +3 -5
  77. data/lib/woods/extractors/manager_extractor.rb +7 -21
  78. data/lib/woods/extractors/migration_declaration.rb +87 -0
  79. data/lib/woods/extractors/migration_extractor.rb +5 -39
  80. data/lib/woods/extractors/phlex_extractor.rb +6 -2
  81. data/lib/woods/extractors/policy_extractor.rb +9 -5
  82. data/lib/woods/extractors/poro_extractor.rb +112 -53
  83. data/lib/woods/extractors/pundit_extractor.rb +11 -6
  84. data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
  85. data/lib/woods/extractors/serializer_extractor.rb +34 -22
  86. data/lib/woods/extractors/shared_utility_methods.rb +18 -1
  87. data/lib/woods/extractors/source_nesting.rb +142 -106
  88. data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
  89. data/lib/woods/extractors/state_machine_extractor.rb +46 -40
  90. data/lib/woods/extractors/view_component_extractor.rb +9 -7
  91. data/lib/woods/flow_assembler.rb +4 -1
  92. data/lib/woods/generation.rb +25 -0
  93. data/lib/woods/hooks/context_hint.rb +7 -2
  94. data/lib/woods/mcp/bearer_auth.rb +1 -1
  95. data/lib/woods/mcp/bootstrapper.rb +33 -7
  96. data/lib/woods/mcp/config_resolver.rb +26 -7
  97. data/lib/woods/mcp/index_reader.rb +125 -24
  98. data/lib/woods/mcp/index_reader_pinning.rb +16 -0
  99. data/lib/woods/mcp/origin_guard.rb +24 -77
  100. data/lib/woods/mcp/origin_policy.rb +124 -0
  101. data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
  102. data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
  103. data/lib/woods/mcp/search_results.rb +7 -1
  104. data/lib/woods/mcp/server.rb +24 -4
  105. data/lib/woods/module_reconciliation.rb +151 -0
  106. data/lib/woods/path_dispatcher.rb +7 -2
  107. data/lib/woods/railtie_support.rb +8 -0
  108. data/lib/woods/rake_helpers.rb +43 -11
  109. data/lib/woods/release.rb +1 -1
  110. data/lib/woods/resilience/index_validator.rb +8 -3
  111. data/lib/woods/resilience/retryable_provider.rb +18 -1
  112. data/lib/woods/resolved_config.rb +68 -8
  113. data/lib/woods/retrieval/context_assembler.rb +3 -3
  114. data/lib/woods/retrieval/lexical_assembler.rb +3 -2
  115. data/lib/woods/retrieval/scope.rb +18 -2
  116. data/lib/woods/retrieval/source_evidence.rb +14 -2
  117. data/lib/woods/source_contributor_validation.rb +78 -0
  118. data/lib/woods/source_contributors.rb +116 -0
  119. data/lib/woods/source_inputs/handoff.rb +37 -0
  120. data/lib/woods/source_inputs/launcher.rb +53 -13
  121. data/lib/woods/source_inputs/manifest.rb +84 -3
  122. data/lib/woods/source_inputs/private_key.rb +44 -12
  123. data/lib/woods/source_inputs/scanner.rb +98 -27
  124. data/lib/woods/source_inputs/scopes.rb +1 -1
  125. data/lib/woods/source_inputs/session.rb +147 -15
  126. data/lib/woods/source_inputs/stable_reader.rb +127 -0
  127. data/lib/woods/source_inputs/status.rb +40 -8
  128. data/lib/woods/source_inputs/verifier.rb +28 -5
  129. data/lib/woods/source_path_encoding.rb +33 -0
  130. data/lib/woods/source_references/cache.rb +284 -0
  131. data/lib/woods/source_references/collector.rb +120 -0
  132. data/lib/woods/source_references/extraction.rb +185 -0
  133. data/lib/woods/source_references/inputs.rb +134 -0
  134. data/lib/woods/source_references/parser_adapter.rb +134 -0
  135. data/lib/woods/source_references/pass.rb +152 -0
  136. data/lib/woods/source_references/prism_adapter.rb +116 -0
  137. data/lib/woods/source_references/registry.rb +178 -0
  138. data/lib/woods/source_references/runtime_lookup.rb +127 -0
  139. data/lib/woods/source_references/value_class.rb +82 -0
  140. data/lib/woods/storage/metadata_store.rb +4 -1
  141. data/lib/woods/storage/qdrant.rb +2 -2
  142. data/lib/woods/unblocked/client.rb +12 -7
  143. data/lib/woods/unblocked/document_builder.rb +4 -1
  144. data/lib/woods/unblocked/exporter.rb +127 -37
  145. data/lib/woods/unblocked/sync_manifest.rb +137 -21
  146. data/lib/woods/unblocked/uri_migration.rb +105 -0
  147. data/lib/woods/util/host_guard.rb +3 -2
  148. data/lib/woods/version.rb +1 -1
  149. data/lib/woods/watch/catch_up.rb +138 -0
  150. data/lib/woods/watch/claim_lease.rb +150 -0
  151. data/lib/woods/watch/cli.rb +26 -2
  152. data/lib/woods/watch/daemon.rb +80 -59
  153. data/lib/woods/watch/installation/options.rb +1 -1
  154. data/lib/woods/watch/installation/receipt.rb +6 -1
  155. data/lib/woods/watch/managed_child.rb +1 -1
  156. data/lib/woods/watch/supervisor.rb +1 -1
  157. data/lib/woods/watch/tree_scan.rb +14 -2
  158. data/plugin/.claude-plugin/plugin.json +1 -1
  159. data/plugin/hooks/adapters/normalize.rb +3 -2
  160. data/plugin/hooks/woods-input-rules.sh +4 -0
  161. data/plugin/hooks/woods-refresh.sh +15 -7
  162. data/plugin/hooks/woods-session-start.sh +60 -3
  163. data/plugin/skills/woods-diagnose/SKILL.md +336 -2
  164. data/plugin/skills/woods-investigate/SKILL.md +11 -0
  165. data/plugin/skills/woods-mcp-config/SKILL.md +86 -0
  166. data/plugin/skills/woods-setup/SKILL.md +56 -1
  167. 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
- # Shared host-header / URL-host canonicalization used by {MCP::OriginGuard}
6
- # and the {Storage::VectorStore::Qdrant} URL validator.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Woods
4
- VERSION = '2.0.0'
4
+ VERSION = '2.1.0'
5
5
  end
@@ -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
@@ -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)