woods 2.0.0.beta4 → 2.0.1

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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +495 -471
  3. data/CONTRIBUTING.md +12 -2
  4. data/README.md +11 -26
  5. data/docs/AGENT_GUIDE.md +31 -12
  6. data/docs/AGENT_SETUP.md +17 -10
  7. data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
  8. data/docs/BACKEND_MATRIX.md +13 -7
  9. data/docs/CLIENT_HOOKS.md +1 -1
  10. data/docs/CONFIGURATION_REFERENCE.md +44 -27
  11. data/docs/CONSOLE_MCP_SETUP.md +95 -16
  12. data/docs/DOCKER_SETUP.md +15 -0
  13. data/docs/EVALUATION.md +10 -4
  14. data/docs/EXTRACTOR_REFERENCE.md +14 -2
  15. data/docs/FAQ.md +14 -3
  16. data/docs/GETTING_STARTED.md +18 -17
  17. data/docs/INCREMENTAL_EXTRACTION.md +8 -3
  18. data/docs/INDEX_LAYOUT.md +2 -2
  19. data/docs/MCP_HTTP_TRANSPORT.md +54 -2
  20. data/docs/MCP_SERVERS.md +28 -11
  21. data/docs/MCP_TOOL_COOKBOOK.md +1 -1
  22. data/docs/MCP_WORKTREE_SETUP.md +13 -1
  23. data/docs/PUBLISHED_INDEX.md +1 -1
  24. data/docs/README.md +2 -1
  25. data/docs/RETRIEVAL_GUIDE.md +57 -8
  26. data/docs/SOURCE_FRESHNESS.md +1 -1
  27. data/docs/TOKEN_BENCHMARK.md +16 -10
  28. data/docs/TROUBLESHOOTING.md +133 -37
  29. data/docs/UPGRADING_TO_2.md +69 -7
  30. data/docs/WATCH_DAEMON.md +172 -17
  31. data/docs/WHY_WOODS.md +9 -5
  32. data/exe/woods-console +13 -11
  33. data/exe/woods-mcp-http +16 -9
  34. data/exe/woods-watch +5 -0
  35. data/lib/generators/woods/watch_generator.rb +53 -0
  36. data/lib/puma/plugin/woods.rb +10 -0
  37. data/lib/tasks/woods.rake +14 -0
  38. data/lib/woods/cache/cache_middleware.rb +18 -11
  39. data/lib/woods/console/adapter_family.rb +39 -0
  40. data/lib/woods/console/credential_index.rb +33 -3
  41. data/lib/woods/console/embedded_executor.rb +401 -43
  42. data/lib/woods/console/model_validator.rb +8 -0
  43. data/lib/woods/console/rack_middleware.rb +39 -10
  44. data/lib/woods/console/redactor.rb +24 -10
  45. data/lib/woods/console/safe_context.rb +44 -7
  46. data/lib/woods/console/sql_noise_stripper.rb +41 -12
  47. data/lib/woods/console/sql_table_scanner.rb +45 -34
  48. data/lib/woods/console/sql_validator.rb +37 -2
  49. data/lib/woods/console/stdio_transport.rb +27 -0
  50. data/lib/woods/extractor.rb +25 -7
  51. data/lib/woods/git_command.rb +6 -7
  52. data/lib/woods/git_provenance.rb +4 -6
  53. data/lib/woods/mcp/bearer_auth.rb +1 -1
  54. data/lib/woods/mcp/bootstrapper.rb +3 -1
  55. data/lib/woods/mcp/initialization_guidance.rb +1 -1
  56. data/lib/woods/mcp/origin_guard.rb +24 -77
  57. data/lib/woods/mcp/origin_policy.rb +124 -0
  58. data/lib/woods/mcp/server.rb +41 -9
  59. data/lib/woods/railtie_support.rb +8 -0
  60. data/lib/woods/retrieval/corpus_status.rb +46 -0
  61. data/lib/woods/retriever.rb +19 -7
  62. data/lib/woods/storage/local_corpus_stats.rb +32 -0
  63. data/lib/woods/storage/metadata_store.rb +20 -0
  64. data/lib/woods/storage/vector_store.rb +10 -0
  65. data/lib/woods/version.rb +1 -1
  66. data/lib/woods/watch/child_environment.rb +30 -0
  67. data/lib/woods/watch/cli.rb +91 -0
  68. data/lib/woods/watch/daemon.rb +55 -7
  69. data/lib/woods/watch/event_stream.rb +70 -0
  70. data/lib/woods/watch/guardian.rb +142 -0
  71. data/lib/woods/watch/installation/layout.rb +70 -0
  72. data/lib/woods/watch/installation/options.rb +128 -0
  73. data/lib/woods/watch/installation/planner.rb +128 -0
  74. data/lib/woods/watch/installation/probe.rb +101 -0
  75. data/lib/woods/watch/installation/receipt.rb +77 -0
  76. data/lib/woods/watch/installation/recovery.rb +64 -0
  77. data/lib/woods/watch/installation/templates.rb +58 -0
  78. data/lib/woods/watch/installation.rb +56 -0
  79. data/lib/woods/watch/lifecycle.rb +182 -0
  80. data/lib/woods/watch/managed_child.rb +113 -0
  81. data/lib/woods/watch/managed_cleanup.rb +48 -0
  82. data/lib/woods/watch/managed_process.rb +144 -0
  83. data/lib/woods/watch/puma_adapter.rb +87 -0
  84. data/lib/woods/watch/puma_child.rb +66 -0
  85. data/lib/woods/watch/supervision_records.rb +95 -0
  86. data/lib/woods/watch/supervision_status.rb +104 -0
  87. data/lib/woods/watch/supervisor.rb +161 -0
  88. data/lib/woods/watch/supervisor_reporting.rb +46 -0
  89. data/plugin/.claude-plugin/plugin.json +1 -1
  90. data/plugin/skills/woods-agent-enable/SKILL.md +1 -1
  91. data/plugin/skills/woods-diagnose/SKILL.md +88 -8
  92. data/plugin/skills/woods-investigate/SKILL.md +6 -6
  93. data/plugin/skills/woods-mcp-config/SKILL.md +43 -1
  94. data/plugin/skills/woods-setup/SKILL.md +66 -4
  95. metadata +37 -5
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative '../../agent_configuration/document'
5
+
6
+ module Woods
7
+ module Watch
8
+ class Installation
9
+ # Absolute paths are transient apply state; receipts retain only relative paths.
10
+ class Layout
11
+ RECEIPT = '.woods-watch.json'
12
+ WRAPPER = 'bin/woods-watch'
13
+ PUMA = 'config/puma.rb'
14
+ PROCFILE = /\AProcfile(?:\.[a-zA-Z0-9_-]+)?\z/
15
+
16
+ attr_reader :root
17
+
18
+ # @param root [String] canonical application root
19
+ # @param procfile [String] selected root-level Procfile
20
+ def initialize(root, procfile)
21
+ @root = root
22
+ @procfile = procfile
23
+ @extra_paths = []
24
+ end
25
+
26
+ # @param path [String] portable target path
27
+ # @return [String] checked absolute path
28
+ def absolute(path)
29
+ unless [RECEIPT, WRAPPER, PUMA].include?(path) || (path.is_a?(String) && PROCFILE.match?(path))
30
+ raise Conflict, "Unsupported watcher installation target: #{path.inspect}"
31
+ end
32
+
33
+ File.join(root, path)
34
+ end
35
+
36
+ # @param paths [Array<String>] prior owned paths read from the receipt
37
+ # @return [void]
38
+ def include_previous(paths)
39
+ @extra_paths = paths.map { |path| absolute(path) }
40
+ end
41
+
42
+ # @return [Array<String>] exact allowed write set
43
+ def allowed_paths
44
+ ([RECEIPT, WRAPPER, PUMA, @procfile].map { |path| absolute(path) } + @extra_paths).uniq
45
+ end
46
+
47
+ # @return [String] local journal stem, separate from the portable receipt
48
+ def receipt_path
49
+ File.join(root, 'tmp', 'woods-watch-install', 'transaction')
50
+ end
51
+
52
+ # @return [Array<String>] local serialization lock
53
+ def lock_paths
54
+ ["#{receipt_path}.lock"]
55
+ end
56
+
57
+ # @return [Hash] transient preview identity, never committed to the receipt
58
+ def identity
59
+ { 'kind' => 'woods-watch-installation', 'root' => root, 'procfile' => @procfile }
60
+ end
61
+
62
+ # @param path [String] portable target path
63
+ # @return [Woods::AgentConfiguration::Document]
64
+ def document(path)
65
+ Woods::AgentConfiguration::Document.new(absolute(path))
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Woods
6
+ module Watch
7
+ class Installation
8
+ # Restricts automatic edits to explicit, verified startup arrangements.
9
+ class Options
10
+ attr_reader :root, :mode, :operation, :child_command, :manager_command, :procfile
11
+
12
+ # @param root [String] application root
13
+ # @param options [Hash] installation choices and injectable probe/environment
14
+ def initialize(root:, **options)
15
+ @root = File.realpath(root)
16
+ @mode = options.fetch(:mode, nil)
17
+ @operation = options.fetch(:operation, 'setup')
18
+ @child_command = argv(options.fetch(:child_command, %w[bin/rails woods:watch]))
19
+ @manager_command = options[:manager_command] && argv(options[:manager_command])
20
+ @procfile = options.fetch(:procfile, 'Procfile.dev')
21
+ @environment = options.fetch(:environment, ENV)
22
+ @probe = options.fetch(:probe) { Probe.new(environment: @environment) }
23
+ end
24
+
25
+ # @return [void]
26
+ def validate!
27
+ validate_selection!
28
+ raise Conflict, 'Select a root-level Procfile explicitly' unless Layout::PROCFILE.match?(procfile.to_s)
29
+ return if operation == 'remove'
30
+
31
+ raise Conflict, 'Application Gemfile is missing' unless File.file?(File.join(root, 'Gemfile'))
32
+
33
+ validate_child!
34
+ validate_idle!
35
+ validate_manager! if mode == 'procfile'
36
+ validate_puma! if mode == 'puma'
37
+ @probe.call(root: root, child_command: child_command,
38
+ manager_command: mode == 'procfile' ? manager_command : nil, puma: mode == 'puma')
39
+ end
40
+
41
+ # @return [Hash] portable selection persisted in the receipt
42
+ def selection
43
+ { 'mode' => mode, 'child_command' => child_command,
44
+ 'manager_command' => mode == 'procfile' ? manager_command : nil,
45
+ 'procfile' => mode == 'procfile' ? procfile : nil }
46
+ end
47
+
48
+ # @return [String] truthful startup handoff, separate from validation evidence
49
+ def handoff
50
+ case mode
51
+ when 'external'
52
+ "Configure one external supervisor to run: #{Shellwords.join(child_command)}; retain raw exit-75 recovery."
53
+ when 'procfile'
54
+ "Start with #{Shellwords.join(manager_command)}. bin/dev was preserved; verify catch-up and an edit."
55
+ else
56
+ 'Start with bin/rails server using config/puma.rb; custom -C startup is unsupported. ' \
57
+ 'Verify catch-up and an edit; other environments start no watcher.'
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def argv(value)
64
+ items = value.is_a?(String) ? Shellwords.split(value) : value
65
+ unless items.is_a?(Array) && !items.empty? && items.all? { |arg| valid_argument?(arg) }
66
+ raise Conflict, 'Commands must be nonempty argument vectors, not shell scripts'
67
+ end
68
+
69
+ items
70
+ rescue ArgumentError => e
71
+ raise Conflict, "Invalid command arguments: #{e.message}"
72
+ end
73
+
74
+ def valid_argument?(argument)
75
+ argument.is_a?(String) && !argument.empty? && !argument.include?("\0")
76
+ end
77
+
78
+ def validate_selection!
79
+ unless %w[setup update remove].include?(operation)
80
+ raise Conflict, 'Supported operations: setup, update, remove'
81
+ end
82
+ return if operation == 'remove' || %w[procfile puma external].include?(mode)
83
+
84
+ raise Conflict, 'Select mode procfile, puma, or external explicitly'
85
+ end
86
+
87
+ def validate_child!
88
+ return if child_command.last == 'woods:watch'
89
+
90
+ raise Conflict, 'Child command must end with woods:watch so preflight can verify the installed task with -T'
91
+ end
92
+
93
+ def validate_idle!
94
+ value = @environment['WOODS_WATCH_IDLE_TIMEOUT']
95
+ return if mode == 'external' || value.nil? || value.empty?
96
+
97
+ raise Conflict, 'Unset WOODS_WATCH_IDLE_TIMEOUT; managed maintenance needs a resident child'
98
+ end
99
+
100
+ def validate_manager!
101
+ unless valid_manager?
102
+ raise Conflict, 'Select verified Foreman startup with -f matching the Procfile; ' \
103
+ 'plain Rails bin/dev needs Puma mode'
104
+ end
105
+ document = Woods::AgentConfiguration::Document.new(File.join(root, procfile))
106
+ raise Conflict, "Selected Procfile does not exist: #{procfile}" unless document.content
107
+ end
108
+
109
+ def validate_puma!
110
+ return unless File.exist?(File.join(root, 'config/puma/development.rb'))
111
+
112
+ raise Conflict, 'Puma selects config/puma/development.rb before config/puma.rb; ' \
113
+ 'use external/Foreman mode for custom Puma configuration'
114
+ end
115
+
116
+ def valid_manager?
117
+ args = manager_command
118
+ return false unless args
119
+
120
+ args = args.drop(2) if args.first(2) == %w[bundle exec]
121
+ return false unless args.length == 4 && File.basename(args[0]) == 'foreman'
122
+
123
+ args[1] == 'start' && %w[-f --procfile].include?(args[2]) && args[3] == procfile
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'templates'
4
+ require_relative 'receipt'
5
+
6
+ module Woods
7
+ module Watch
8
+ class Installation
9
+ # Builds one conflict-checked transaction while preserving unowned bytes.
10
+ class Planner
11
+ # @param layout [Layout] destination and local transaction paths
12
+ # @param options [Options] preflight-validated selections
13
+ def initialize(layout:, options:)
14
+ @layout = layout
15
+ @options = options
16
+ @documents = {}
17
+ @contents = {}
18
+ @modes = {}
19
+ end
20
+
21
+ # @return [Woods::AgentConfiguration::Plan] complete preview
22
+ def call
23
+ @receipt = Receipt.new(document(Layout::RECEIPT))
24
+ @layout.include_previous(@receipt.paths)
25
+ @plan = Woods::AgentConfiguration::Plan.new(layout: @layout, operation: @options.operation)
26
+ validate_operation!
27
+ remove_previous
28
+ install unless @options.operation == 'remove'
29
+ publish_plan
30
+ end
31
+
32
+ private
33
+
34
+ def validate_operation!
35
+ previous = @receipt.data
36
+ if @options.operation == 'update' && !previous
37
+ raise Conflict, 'No owned watcher installation to update; run setup first'
38
+ end
39
+ return unless previous && @options.operation == 'setup' && previous.fetch('selection') != @options.selection
40
+
41
+ raise Conflict, 'Watcher setup selection changed; use --operation update to switch modes or commands'
42
+ end
43
+
44
+ def document(path)
45
+ @documents[path] ||= @layout.document(path)
46
+ end
47
+
48
+ def content(path)
49
+ @contents.key?(path) ? @contents[path] : document(path).content
50
+ end
51
+
52
+ def remove_previous
53
+ return unless @receipt.data
54
+
55
+ @receipt.data.fetch('files').each do |path, expected|
56
+ Receipt.verify_file!(document(path), expected)
57
+ @contents[path] = nil
58
+ end
59
+ @receipt.data.fetch('sections').each { |path, record| remove_section(path, record) }
60
+ @contents[Layout::RECEIPT] = nil
61
+ end
62
+
63
+ def remove_section(path, record)
64
+ text = document(path).content || ''
65
+ block = record.fetch('owned_text')
66
+ unless text.scan(Templates::START).size == 1 && text.scan(Templates::FINISH).size == 1 && text.include?(block)
67
+ raise Conflict, "Owned watcher section was edited or removed: #{path}"
68
+ end
69
+
70
+ remaining = text.sub(block, '')
71
+ @contents[path] = record.fetch('created_file') && remaining.empty? ? nil : remaining
72
+ end
73
+
74
+ def install
75
+ @new_receipt = { 'schema_version' => 1, 'selection' => @options.selection, 'files' => {}, 'sections' => {} }
76
+ if @options.mode != 'external'
77
+ install_wrapper
78
+ install_section
79
+ end
80
+ @contents[Layout::RECEIPT] = "#{JSON.pretty_generate(@new_receipt)}\n"
81
+ @modes[Layout::RECEIPT] = 0o644
82
+ end
83
+
84
+ def install_wrapper
85
+ path = Layout::WRAPPER
86
+ raise Conflict, 'Refusing to overwrite an unowned bin/woods-watch' if content(path)
87
+
88
+ text = Templates.wrapper(@options.child_command)
89
+ @contents[path] = text
90
+ @modes[path] = 0o755
91
+ @new_receipt.fetch('files')[path] = { 'sha256' => Digest::SHA256.hexdigest(text), 'mode' => 0o755, 'exists' => true }
92
+ end
93
+
94
+ def install_section
95
+ path = @options.mode == 'puma' ? Layout::PUMA : @options.procfile
96
+ text = content(path) || ''
97
+ reject_unowned_section!(text)
98
+ previous = @receipt.data&.dig('sections', path, 'owned_text')
99
+ block = Templates.section(@options.mode, text, previous: previous, update: @options.operation == 'update')
100
+ @contents[path] = previous ? document(path).content.sub(previous, block) : text + block
101
+ @modes[path] = document(path).content ? document(path).mode : 0o644
102
+ @new_receipt.fetch('sections')[path] = { 'owned_text' => block, 'created_file' => content_created?(path) }
103
+ end
104
+
105
+ def content_created?(path)
106
+ @receipt.data&.dig('sections', path, 'created_file') || document(path).content.nil?
107
+ end
108
+
109
+ def reject_unowned_section!(text)
110
+ conflict = text.include?(Templates::START) || text.include?(Templates::FINISH)
111
+ pattern = @options.mode == 'puma' ? /^\s*plugin(?:\s+|\s*\(\s*)[:'"]woods\b/ : /^\s*woods\s*:/
112
+ conflict ||= text.match?(pattern)
113
+ raise Conflict, 'Unowned Woods startup directive or managed markers already exist' if conflict
114
+ end
115
+
116
+ def publish_plan
117
+ @contents.each do |path, text|
118
+ current = document(path)
119
+ @plan.add(current, text, description: "#{@options.operation} Woods watcher ownership in #{path}")
120
+ change = @plan.data.fetch('changes').find { |entry| entry['path'] == current.path }
121
+ change['mode'] = @modes.fetch(path, current.mode) if change && text
122
+ end
123
+ @plan
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'timeout'
5
+ require_relative '../child_environment'
6
+
7
+ module Woods
8
+ module Watch
9
+ class Installation
10
+ # Read-only, bounded task and manager checks; never starts the watch task.
11
+ class Probe
12
+ PUMA_CHECK = <<~RUBY
13
+ require 'puma'
14
+ version = Gem.loaded_specs.fetch('puma').version
15
+ supported = version >= Gem::Version.new('6') && version < Gem::Version.new('9') &&
16
+ !RUBY_PLATFORM.match?(/mswin|mingw|cygwin|java/)
17
+ abort 'Supported Puma 6/7/8 on a native Unix Ruby is required; use external supervision' unless supported
18
+ puts version
19
+ RUBY
20
+
21
+ # @param environment [Hash] selected application environment
22
+ # @param timeout [Numeric] deadline for each task/manager probe
23
+ def initialize(environment: ENV, timeout: 30)
24
+ @environment = environment
25
+ @timeout = timeout
26
+ end
27
+
28
+ # @param root [String] application root
29
+ # @param child_command [Array<String>] argv ending in woods:watch
30
+ # @param manager_command [Array<String>, nil] verified Foreman start argv
31
+ # @param puma [Boolean] verify installed supported Puma in the application bundle
32
+ # @return [true]
33
+ def call(root:, child_command:, manager_command: nil, puma: false)
34
+ output = run(root, child_command[0...-1] + ['-T', 'woods:watch'])
35
+ unless output.match?(/\bwoods:watch\b/)
36
+ raise Conflict, 'Selected application command does not expose woods:watch; select its Rails task entrypoint'
37
+ end
38
+
39
+ run(root, manager_command[0...-3] + ['--version']) if manager_command
40
+ run(root, ['bundle', 'exec', Gem.ruby, '-e', PUMA_CHECK]) if puma
41
+ true
42
+ end
43
+
44
+ private
45
+
46
+ def run(root, command)
47
+ Open3.popen3(probe_environment(root), *command, chdir: root, pgroup: true,
48
+ unsetenv_others: true) do |input, output, error, child|
49
+ input.close
50
+ collect(output, error, child)
51
+ end
52
+ rescue SystemCallError => e
53
+ raise Conflict, "Startup preflight could not run: #{e.message}"
54
+ end
55
+
56
+ def probe_environment(root)
57
+ environment = ChildEnvironment.build(@environment.to_h, root: root)
58
+ environment['BUNDLE_GEMFILE'] ||= File.join(root, 'Gemfile')
59
+ # A caller-selected lockfile without Bundler's activation markers is
60
+ # explicit configuration too; otherwise prefer the restored original.
61
+ unless @environment.key?('BUNDLER_ORIG_BUNDLE_LOCKFILE')
62
+ environment['BUNDLE_LOCKFILE'] ||= @environment['BUNDLE_LOCKFILE']
63
+ end
64
+ environment['BUNDLE_LOCKFILE'] ||= "#{environment['BUNDLE_GEMFILE']}.lock"
65
+ environment.merge('BUNDLE_FROZEN' => 'true')
66
+ end
67
+
68
+ def collect(output, error, child)
69
+ readers = [output, error].map { |io| Thread.new { bounded_read(io) } }
70
+ Timeout.timeout(@timeout) do
71
+ result, diagnostic = readers.map(&:value)
72
+ raise Conflict, "Startup preflight failed: #{diagnostic.strip}" unless child.value.success?
73
+
74
+ result
75
+ end
76
+ rescue Timeout::Error
77
+ raise Conflict, "Startup preflight exceeded #{@timeout}s; inspect the application task command"
78
+ ensure
79
+ terminate(child.pid)
80
+ readers.each(&:kill)
81
+ end
82
+
83
+ def bounded_read(io)
84
+ output = String.new
85
+ loop do
86
+ output << io.readpartial(16_384)
87
+ raise Conflict, 'Startup preflight exceeded 1 MiB of output' 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
101
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Woods
6
+ module Watch
7
+ class Installation
8
+ # Validates portable ownership before any current file is changed.
9
+ class Receipt
10
+ attr_reader :data
11
+
12
+ # @param document [Woods::AgentConfiguration::Document] portable receipt
13
+ def initialize(document)
14
+ @data = document.content && document.json
15
+ validate! if data
16
+ end
17
+
18
+ # @return [Array<String>] portable paths recorded by this installation
19
+ def paths
20
+ return [] unless data
21
+
22
+ data.fetch('files').keys + data.fetch('sections').keys
23
+ end
24
+
25
+ # @param document [Woods::AgentConfiguration::Document] owned file
26
+ # @param expected [Hash] prior content fingerprint and mode
27
+ # @return [void]
28
+ def self.verify_file!(document, expected)
29
+ return if document.fingerprint == expected
30
+
31
+ raise Conflict, "Owned watcher file was edited or removed: #{document.path}"
32
+ end
33
+
34
+ private
35
+
36
+ def validate!
37
+ unless data['schema_version'] == 1 && data['selection'].is_a?(Hash) &&
38
+ %w[procfile puma external].include?(data.dig('selection', 'mode')) &&
39
+ data['files'].is_a?(Hash) && data['sections'].is_a?(Hash)
40
+ raise Conflict, 'Malformed watcher installation receipt; restore its owned metadata'
41
+ end
42
+
43
+ validate_files!
44
+ validate_sections!
45
+ end
46
+
47
+ def validate_files!
48
+ data.fetch('files').each do |path, record|
49
+ next if path == Layout::WRAPPER && valid_file_record?(record)
50
+
51
+ raise Conflict, 'Invalid owned watcher executable in receipt'
52
+ end
53
+ end
54
+
55
+ def validate_sections!
56
+ data.fetch('sections').each do |path, record|
57
+ valid_path = path == Layout::PUMA || Layout::PROCFILE.match?(path)
58
+ next if valid_path && valid_section_record?(record)
59
+
60
+ raise Conflict, 'Invalid owned watcher section in receipt'
61
+ end
62
+ end
63
+
64
+ def valid_file_record?(record)
65
+ record.is_a?(Hash) && record['exists'] == true && record['mode'] == 0o755 &&
66
+ record['sha256'].is_a?(String) && record['sha256'].match?(/\A[0-9a-f]{64}\z/)
67
+ end
68
+
69
+ def valid_section_record?(record)
70
+ record.is_a?(Hash) && [true, false].include?(record['created_file']) &&
71
+ record['owned_text'].is_a?(String) && record['owned_text'].include?(Templates::START) &&
72
+ record['owned_text'].include?(Templates::FINISH)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Watch
5
+ class Installation
6
+ # Replays only validated local transaction state; never discovers or boots Rails.
7
+ class Recovery
8
+ # @param root [String] selected canonical application directory
9
+ def initialize(root:)
10
+ @root = root
11
+ end
12
+
13
+ # @param pretend [Boolean] check journal and current snapshots without writes
14
+ # @return [String] recovery status
15
+ def call(pretend: false)
16
+ journal = read_journal
17
+ return 'nothing_to_recover' unless journal
18
+
19
+ layout = recovery_layout(journal)
20
+ applier = Woods::AgentConfiguration::Applier.new(layout: layout)
21
+ return applier.recover unless pretend
22
+
23
+ verify_preview(applier, layout, journal)
24
+ 'recovery_preview_verified'
25
+ rescue KeyError, TypeError, ArgumentError => e
26
+ raise Conflict, "Invalid watcher recovery journal: #{e.message}"
27
+ end
28
+
29
+ private
30
+
31
+ def read_journal
32
+ layout = Layout.new(@root, 'Procfile.dev')
33
+ document = Woods::AgentConfiguration::Document.new("#{layout.receipt_path}.pending")
34
+ document.content && document.json
35
+ end
36
+
37
+ def recovery_layout(journal)
38
+ plan = journal.fetch('plan')
39
+ identity = plan.fetch('layout')
40
+ raise Conflict, 'Recovery journal belongs to another application' unless identity.fetch('root') == @root
41
+
42
+ layout = Layout.new(@root, identity.fetch('procfile'))
43
+ paths = plan.fetch('changes').map do |change|
44
+ path = change.fetch('path')
45
+ raise Conflict, 'Recovery target is outside the application' unless path.start_with?("#{@root}/")
46
+
47
+ path.delete_prefix("#{@root}/")
48
+ end
49
+ layout.include_previous(paths)
50
+ layout
51
+ end
52
+
53
+ def verify_preview(applier, layout, journal)
54
+ plan = Woods::AgentConfiguration::Plan.allocate
55
+ plan.instance_variable_set(:@data, journal.fetch('plan'))
56
+ plan.validate!(layout)
57
+ changes = plan.data.fetch('changes')
58
+ applier.send(:validate_originals!, journal.fetch('originals'), changes)
59
+ changes.each { |change| applier.send(:validate_recovery_snapshot!, change) }
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Watch
5
+ class Installation
6
+ # Small generated fragments; runtime identities never enter committed files.
7
+ module Templates
8
+ START = '# woods:watch:managed:start'
9
+ FINISH = '# woods:watch:managed:end'
10
+
11
+ module_function
12
+
13
+ # @param child_command [Array<String>] explicit task argv
14
+ # @return [String] application-relative executable source
15
+ def wrapper(child_command)
16
+ <<~RUBY
17
+ #!/usr/bin/env ruby
18
+ # frozen_string_literal: true
19
+
20
+ # Generated by Woods; use woods:watch --operation update to change owned setup.
21
+ root = File.expand_path('..', __dir__)
22
+ ENV['BUNDLE_GEMFILE'] ||= File.join(root, 'Gemfile')
23
+ require 'bundler/setup'
24
+ Dir.chdir(root)
25
+ exec Gem.ruby, Gem.bin_path('woods', 'woods-watch'), '--root', root,
26
+ *ARGV, '--', *#{child_command.inspect}
27
+ RUBY
28
+ end
29
+
30
+ # @param mode [String] procfile or puma
31
+ # @return [String] owned startup directive
32
+ def directive(mode)
33
+ return 'woods: bin/woods-watch' unless mode == 'puma'
34
+
35
+ 'plugin :woods if Gem.loaded_specs["woods"]&.full_require_paths&.any? ' \
36
+ '{ |path| File.file?(File.join(path, "puma/plugin/woods.rb")) }'
37
+ end
38
+
39
+ # @param mode [String] selected startup mode
40
+ # @param text [String] surrounding file contents
41
+ # @param previous [String, nil] existing receipt-owned block
42
+ # @param update [Boolean] refresh an existing block in place
43
+ # @return [String] exact appended owned block including separator
44
+ def section(mode, text, previous: nil, update: false)
45
+ if previous
46
+ return previous unless update
47
+
48
+ return previous.partition(START).first + section(mode, previous)
49
+ end
50
+
51
+ newline = text.include?("\r\n") ? "\r\n" : "\n"
52
+ separator = text.empty? || text.end_with?(newline) ? '' : newline
53
+ separator + [START, directive(mode), FINISH, ''].join(newline)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../agent_configuration/applier'
5
+ require_relative '../agent_configuration/plan'
6
+ require_relative 'installation/layout'
7
+ require_relative 'installation/options'
8
+ require_relative 'installation/planner'
9
+ require_relative 'installation/probe'
10
+ require_relative 'installation/recovery'
11
+
12
+ module Woods
13
+ module Watch
14
+ # Plans and applies portable, explicitly owned watcher startup configuration.
15
+ class Installation
16
+ Conflict = Woods::AgentConfiguration::Conflict
17
+
18
+ # @param root [String] application directory
19
+ # @param options [Hash] mode, operation, child/manager argv and preflight options
20
+ def initialize(root:, **options)
21
+ @options = Options.new(root: root, **options)
22
+ @layout = Layout.new(@options.root, @options.procfile)
23
+ end
24
+
25
+ # Preview all edits without creating files or starting a watcher.
26
+ # @return [Woods::AgentConfiguration::Plan]
27
+ def plan
28
+ @options.validate!
29
+ Planner.new(layout: @layout, options: @options).call
30
+ end
31
+
32
+ # Apply a fresh or previously reviewed plan using conflict-checked writes.
33
+ # @param reviewed_plan [Woods::AgentConfiguration::Plan, nil] optional preview
34
+ # @return [String] applied or already_applied
35
+ def apply(reviewed_plan = nil)
36
+ selected = reviewed_plan || plan
37
+ return 'already_applied' if selected.data.fetch('changes').empty?
38
+
39
+ Woods::AgentConfiguration::Applier.new(layout: @layout).apply(selected)
40
+ end
41
+
42
+ # Explain which process must own this installation.
43
+ # @return [String]
44
+ def handoff
45
+ @options.handoff
46
+ end
47
+
48
+ # Recover an interrupted transaction without booting the application.
49
+ # @param pretend [Boolean] validate the journal without restoring files
50
+ # @return [String] recovery outcome
51
+ def recover(pretend: false)
52
+ Recovery.new(root: @options.root).call(pretend: pretend)
53
+ end
54
+ end
55
+ end
56
+ end