woods 2.0.0.beta3 → 2.0.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +500 -420
  3. data/CONTRIBUTING.md +29 -17
  4. data/README.md +78 -178
  5. data/docs/AGENT_GUIDE.md +52 -11
  6. data/docs/AGENT_SETUP.md +34 -17
  7. data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
  8. data/docs/BACKEND_MATRIX.md +18 -7
  9. data/docs/CLIENT_HOOKS.md +1 -1
  10. data/docs/CONFIGURATION_REFERENCE.md +105 -29
  11. data/docs/CONSOLE_MCP_SETUP.md +54 -9
  12. data/docs/DOCKER_SETUP.md +16 -1
  13. data/docs/EVALUATION.md +10 -4
  14. data/docs/EXTRACTOR_REFERENCE.md +23 -3
  15. data/docs/FAQ.md +14 -3
  16. data/docs/GETTING_STARTED.md +18 -17
  17. data/docs/INCREMENTAL_EXTRACTION.md +37 -8
  18. data/docs/INDEX_LAYOUT.md +2 -2
  19. data/docs/MCP_SERVERS.md +79 -7
  20. data/docs/MCP_TOOL_COOKBOOK.md +5 -5
  21. data/docs/MCP_WORKTREE_SETUP.md +55 -83
  22. data/docs/PUBLISHED_INDEX.md +17 -0
  23. data/docs/README.md +2 -1
  24. data/docs/RETRIEVAL_GUIDE.md +81 -13
  25. data/docs/SOURCE_FRESHNESS.md +1 -1
  26. data/docs/TOKEN_BENCHMARK.md +16 -10
  27. data/docs/TROUBLESHOOTING.md +142 -47
  28. data/docs/UPGRADING_TO_2.md +12 -6
  29. data/docs/WATCH_DAEMON.md +189 -24
  30. data/docs/WHY_WOODS.md +9 -5
  31. data/exe/woods-console +13 -11
  32. data/exe/woods-mcp-start +14 -9
  33. data/exe/woods-watch +5 -0
  34. data/lib/generators/woods/pgvector_generator.rb +8 -2
  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/agent_configuration/applier.rb +5 -3
  39. data/lib/woods/agent_configuration/cli.rb +2 -2
  40. data/lib/woods/agent_configuration/layout.rb +13 -0
  41. data/lib/woods/cache/cache_middleware.rb +6 -0
  42. data/lib/woods/console/credential_scanner.rb +4 -3
  43. data/lib/woods/console/dispatch_pipeline.rb +7 -0
  44. data/lib/woods/console/embedded_executor.rb +31 -9
  45. data/lib/woods/console/sql_noise_stripper.rb +9 -7
  46. data/lib/woods/console/sql_table_scanner.rb +47 -7
  47. data/lib/woods/console/sql_validator.rb +49 -9
  48. data/lib/woods/console/sqlite_read_guard.rb +46 -0
  49. data/lib/woods/console/stdio_transport.rb +27 -0
  50. data/lib/woods/coordination/pipeline_lock.rb +3 -2
  51. data/lib/woods/embedding/indexer.rb +24 -14
  52. data/lib/woods/extractor.rb +70 -19
  53. data/lib/woods/extractors/declared_parent.rb +55 -0
  54. data/lib/woods/extractors/graphql_extractor.rb +2 -11
  55. data/lib/woods/extractors/lib_extractor.rb +10 -8
  56. data/lib/woods/extractors/mailer_extractor.rb +6 -10
  57. data/lib/woods/extractors/model_extractor.rb +1 -15
  58. data/lib/woods/extractors/poro_extractor.rb +10 -8
  59. data/lib/woods/extractors/shared_utility_methods.rb +22 -5
  60. data/lib/woods/git_command.rb +6 -7
  61. data/lib/woods/git_provenance.rb +4 -6
  62. data/lib/woods/mcp/bearer_auth.rb +2 -1
  63. data/lib/woods/mcp/bootstrapper.rb +20 -5
  64. data/lib/woods/mcp/config_resolver.rb +2 -1
  65. data/lib/woods/mcp/index_reader.rb +11 -2
  66. data/lib/woods/mcp/initialization_guidance.rb +1 -1
  67. data/lib/woods/mcp/renderers/markdown_renderer.rb +14 -8
  68. data/lib/woods/mcp/renderers/plain_renderer.rb +11 -7
  69. data/lib/woods/mcp/server.rb +63 -37
  70. data/lib/woods/mcp/tool_contract.rb +1 -1
  71. data/lib/woods/mcp/tool_response_renderer.rb +16 -0
  72. data/lib/woods/mcp/traversal_evidence_text.rb +1 -1
  73. data/lib/woods/mcp/traversal_response.rb +22 -0
  74. data/lib/woods/path_dispatcher.rb +6 -5
  75. data/lib/woods/published_index/typed_unit_reader.rb +40 -3
  76. data/lib/woods/published_index.rb +2 -2
  77. data/lib/woods/rake_helpers.rb +2 -12
  78. data/lib/woods/retrieval/corpus_status.rb +46 -0
  79. data/lib/woods/retrieval/lexical_assembler.rb +14 -3
  80. data/lib/woods/retrieval/lexical_index.rb +2 -1
  81. data/lib/woods/retriever.rb +19 -7
  82. data/lib/woods/session_tracer/file_store.rb +6 -1
  83. data/lib/woods/source_inputs/consumer_errors.rb +4 -0
  84. data/lib/woods/storage/local_corpus_stats.rb +32 -0
  85. data/lib/woods/storage/metadata_store.rb +20 -0
  86. data/lib/woods/storage/pgvector.rb +6 -2
  87. data/lib/woods/storage/vector_store.rb +10 -0
  88. data/lib/woods/temporal/json_snapshot_store.rb +35 -7
  89. data/lib/woods/version.rb +1 -1
  90. data/lib/woods/watch/child_environment.rb +30 -0
  91. data/lib/woods/watch/cli.rb +91 -0
  92. data/lib/woods/watch/daemon.rb +73 -11
  93. data/lib/woods/watch/event_stream.rb +70 -0
  94. data/lib/woods/watch/guardian.rb +142 -0
  95. data/lib/woods/watch/installation/layout.rb +70 -0
  96. data/lib/woods/watch/installation/options.rb +128 -0
  97. data/lib/woods/watch/installation/planner.rb +128 -0
  98. data/lib/woods/watch/installation/probe.rb +101 -0
  99. data/lib/woods/watch/installation/receipt.rb +77 -0
  100. data/lib/woods/watch/installation/recovery.rb +64 -0
  101. data/lib/woods/watch/installation/templates.rb +58 -0
  102. data/lib/woods/watch/installation.rb +56 -0
  103. data/lib/woods/watch/lifecycle.rb +182 -0
  104. data/lib/woods/watch/managed_child.rb +113 -0
  105. data/lib/woods/watch/managed_cleanup.rb +48 -0
  106. data/lib/woods/watch/managed_process.rb +144 -0
  107. data/lib/woods/watch/puma_adapter.rb +87 -0
  108. data/lib/woods/watch/puma_child.rb +66 -0
  109. data/lib/woods/watch/supervision_records.rb +95 -0
  110. data/lib/woods/watch/supervision_status.rb +104 -0
  111. data/lib/woods/watch/supervisor.rb +161 -0
  112. data/lib/woods/watch/supervisor_reporting.rb +46 -0
  113. data/plugin/.claude-plugin/plugin.json +1 -1
  114. data/plugin/hooks/woods-input-rules.sh +4 -4
  115. data/plugin/skills/woods-agent-enable/SKILL.md +7 -1
  116. data/plugin/skills/woods-diagnose/SKILL.md +134 -34
  117. data/plugin/skills/woods-investigate/SKILL.md +54 -15
  118. data/plugin/skills/woods-mcp-config/SKILL.md +38 -11
  119. data/plugin/skills/woods-setup/SKILL.md +72 -15
  120. metadata +38 -5
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Woods
6
+ module Watch
7
+ # Bounded framing plus the guardian-issued child identity used for cleanup.
8
+ class EventStream
9
+ MAX_BYTES = 4096
10
+
11
+ def initialize(reader:, guardian:, launcher:, attempt:)
12
+ @reader = reader
13
+ @guardian = guardian
14
+ @identity = { 'launcher' => launcher, 'attempt' => attempt, 'pid' => guardian, 'event' => 'spawned' }
15
+ @buffer = +''
16
+ end
17
+
18
+ attr_reader :child_pid
19
+
20
+ def read
21
+ records = []
22
+ return records if @reader.closed?
23
+
24
+ 16.times do
25
+ part = @reader.read_nonblock(MAX_BYTES, exception: false)
26
+ break if part == :wait_readable
27
+ return finish(records) if part.nil?
28
+
29
+ @buffer << part
30
+ drain_lines(records)
31
+ end
32
+ records
33
+ end
34
+
35
+ def eof?
36
+ @eof == true
37
+ end
38
+
39
+ private
40
+
41
+ def finish(records)
42
+ @eof = true
43
+ raise ArgumentError, 'truncated Woods lifecycle record' unless @buffer.empty?
44
+
45
+ records
46
+ end
47
+
48
+ def drain_lines(records)
49
+ while @buffer.include?("\n")
50
+ record = @buffer.slice!(0..@buffer.index("\n"))
51
+ raise ArgumentError, 'oversized Woods lifecycle record' if record.bytesize > MAX_BYTES
52
+
53
+ remember_child(record)
54
+ records << record
55
+ end
56
+ raise ArgumentError, 'oversized Woods lifecycle record' if @buffer.bytesize > MAX_BYTES
57
+ end
58
+
59
+ def remember_child(line)
60
+ record = JSON.parse(line)
61
+ return unless record.is_a?(Hash) && @identity.all? { |key, value| record[key] == value }
62
+
63
+ child = record['child_pid']
64
+ @child_pid = child if child.is_a?(Integer) && child.positive?
65
+ rescue JSON::ParserError
66
+ nil # The lifecycle validator reports malformed records.
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'io/wait'
5
+
6
+ module Woods
7
+ module Watch
8
+ # Minimal pre-Bundler owner. EOF on its private parent pipe always stops
9
+ # the owned application group, even while an initializer is blocked.
10
+ class Guardian
11
+ # @param parent [IO] private parent-liveness reader
12
+ # @param events [IO] lifecycle writer
13
+ # @param config [Hash] argument vector and child environment
14
+ def initialize(parent:, events:, config:)
15
+ @parent = parent
16
+ @events = events
17
+ @config = config
18
+ @events.sync = true
19
+ @stopping = false
20
+ end
21
+
22
+ # @return [Integer] application exit status, or launch failure
23
+ def run
24
+ %w[INT TERM].each { |signal| Signal.trap(signal) { @stopping = true } }
25
+ report('hello')
26
+ spawn_child
27
+ monitor
28
+ report('exit', code: @status&.exitstatus, signal: @status&.termsig)
29
+ @status&.exitstatus || 1
30
+ rescue SystemCallError => e
31
+ report('spawn_error', reason: e.class.name.split('::').last)
32
+ 127
33
+ ensure
34
+ terminate_group
35
+ end
36
+
37
+ private
38
+
39
+ def spawn_child
40
+ gate, release = IO.pipe
41
+ @child = fork do
42
+ release.close
43
+ @parent.close
44
+ Process.setpgrp
45
+ exit! 1 unless gate.read(1) == 'S'
46
+
47
+ gate.close
48
+ exec_child
49
+ end
50
+ gate.close
51
+ report('spawned', child_pid: @child)
52
+ release.write('S') if acknowledged?
53
+ ensure
54
+ release&.close unless release&.closed?
55
+ end
56
+
57
+ # Do not execute application code until its owner knows the child group.
58
+ # Guardian death before acknowledgment closes the exec gate with EOF.
59
+ def acknowledged?
60
+ until @stopping || Process.ppid != @config.fetch('owner_pid')
61
+ next unless @parent.wait_readable(0.05)
62
+
63
+ return @parent.read(1) == 'S'
64
+ end
65
+ false
66
+ end
67
+
68
+ def exec_child
69
+ options = { chdir: @config.fetch('root'), in: File::NULL, close_others: true, unsetenv_others: true }
70
+ options[3] = @events if @config['events']
71
+ command = @config.fetch('command')
72
+ Process.exec(@config.fetch('env'), [command.first, command.first], *command.drop(1), **options)
73
+ rescue SystemCallError => e
74
+ warn "[woods-watch] child executable could not start (#{e.class})"
75
+ exit! 127
76
+ end
77
+
78
+ def monitor
79
+ until @stopping
80
+ break if Process.ppid != @config.fetch('owner_pid')
81
+
82
+ result = Process.waitpid2(@child, Process::WNOHANG)
83
+ if result
84
+ @status = result.last
85
+ return
86
+ end
87
+ @stopping = true if @parent.wait_readable(0.05) && @parent.read_nonblock(1, exception: false).nil?
88
+ end
89
+ terminate_group
90
+ end
91
+
92
+ def terminate_group
93
+ return unless @child && !@terminated
94
+
95
+ signal_group('TERM')
96
+ await_child
97
+ signal_group('KILL')
98
+ @status ||= Process.waitpid2(@child).last
99
+ @terminated = true
100
+ rescue Errno::ECHILD
101
+ nil
102
+ end
103
+
104
+ def await_child
105
+ deadline = monotonic + @config.fetch('shutdown_timeout')
106
+ until @status || monotonic >= deadline
107
+ @status = Process.waitpid2(@child, Process::WNOHANG)&.last
108
+ sleep 0.02 unless @status
109
+ end
110
+ end
111
+
112
+ def signal_group(signal)
113
+ Process.kill(signal, -@child)
114
+ rescue Errno::ESRCH
115
+ nil
116
+ end
117
+
118
+ def monotonic
119
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
120
+ end
121
+
122
+ def report(event, **fields)
123
+ record = { version: 1, event: event, pid: Process.pid,
124
+ launcher: @config['launcher'], attempt: @config['attempt'] }.merge(fields)
125
+ @events.write(JSON.generate(record) << "\n")
126
+ rescue Errno::EPIPE
127
+ @stopping = true
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ if $PROGRAM_NAME == __FILE__
134
+ config_io = IO.for_fd(5)
135
+ config = JSON.parse(config_io.read)
136
+ config_io.close
137
+ parent = IO.for_fd(4)
138
+ events = IO.for_fd(3, 'w')
139
+ parent.close_on_exec = true
140
+ events.close_on_exec = true
141
+ exit Woods::Watch::Guardian.new(parent: parent, events: events, config: config).run
142
+ end
@@ -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