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,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'managed_child'
5
+
6
+ module Woods
7
+ module Watch
8
+ # Validates the private attempt protocol independently of application logs.
9
+ class Lifecycle # rubocop:disable Metrics/ClassLength -- one attempt's ordered protocol state must stay together
10
+ # @param launcher [String] expected launcher token
11
+ # @param attempt [String] expected attempt token
12
+ # @param root [String] selected application root
13
+ def initialize(launcher:, attempt:, root:)
14
+ @launcher = launcher
15
+ @attempt = attempt
16
+ @root = File.realpath(root)
17
+ end
18
+
19
+ # @return [String, nil] resolved index directory, only after Rails boot
20
+ attr_reader :index, :child_pid, :terminal, :exit_code, :state, :reason
21
+
22
+ # @return [Boolean] Rails has resolved the application/index identity
23
+ def booted?
24
+ !@index.nil?
25
+ end
26
+
27
+ # @return [Boolean] guardian explicitly reported the application's exit
28
+ def finished?
29
+ @exited == true
30
+ end
31
+
32
+ # @param line [String] bounded JSON event from the private descriptor
33
+ # @return [Hash] validated event
34
+ def accept(line)
35
+ raise ArgumentError, 'oversized Woods lifecycle record' if line.bytesize > ManagedChild::MAX_BYTES
36
+
37
+ record = JSON.parse(line)
38
+ validate_envelope!(record)
39
+ event = record.fetch('event')
40
+ raise ArgumentError, 'event after guardian exit' if @exited
41
+
42
+ if %w[hello spawned exit spawn_error].include?(event)
43
+ guardian_event(record)
44
+ else
45
+ task_event(record)
46
+ end
47
+ record
48
+ rescue JSON::ParserError, KeyError, TypeError, SystemCallError
49
+ raise ArgumentError, 'invalid Woods lifecycle protocol'
50
+ end
51
+
52
+ private
53
+
54
+ def validate_envelope!(record)
55
+ return if record.is_a?(Hash) && record['version'] == 1 && record['launcher'] == @launcher &&
56
+ record['attempt'] == @attempt && record['pid'].is_a?(Integer) && record['pid'].positive?
57
+
58
+ raise ArgumentError, 'invalid Woods lifecycle envelope'
59
+ end
60
+
61
+ def guardian_event(record)
62
+ case record['event']
63
+ when 'hello'
64
+ raise ArgumentError, 'duplicate guardian hello' if @guardian
65
+
66
+ @guardian = record['pid']
67
+ when 'spawned'
68
+ spawned(record)
69
+ when 'exit', 'spawn_error'
70
+ validate_guardian!(record)
71
+ validate_exit!(record)
72
+ @exit_code = record['event'] == 'spawn_error' ? 127 : record['code']
73
+ @exited = true
74
+ end
75
+ end
76
+
77
+ def spawned(record)
78
+ validate_guardian!(record)
79
+ raise ArgumentError, 'duplicate child spawn' if @spawned
80
+
81
+ pid = record['child_pid']
82
+ raise ArgumentError, 'invalid child PID' unless pid.is_a?(Integer) && pid.positive?
83
+ raise ArgumentError, 'child PID changed' if @child_pid && @child_pid != pid
84
+
85
+ @child_pid = pid
86
+ @spawned = true
87
+ end
88
+
89
+ def validate_exit!(record)
90
+ return if record['event'] == 'spawn_error' && !@spawned
91
+ return if record['event'] == 'exit' && @spawned && exit_status_valid?(record)
92
+
93
+ raise ArgumentError, 'invalid guardian exit'
94
+ end
95
+
96
+ def exit_status_valid?(record)
97
+ code, signal = record.values_at('code', 'signal')
98
+ return code.between?(0, 255) if code.is_a?(Integer)
99
+
100
+ code.nil? && signal.is_a?(Integer) && signal.positive?
101
+ end
102
+
103
+ def validate_guardian!(record)
104
+ raise ArgumentError, 'guardian identity mismatch' unless @guardian == record['pid']
105
+ end
106
+
107
+ def task_event(record)
108
+ validate_task_identity!(record)
109
+ event = record['event']
110
+ case event
111
+ when 'task_loaded' then load_task(record)
112
+ when 'identity' then resolve_identity(record)
113
+ when 'backend_ready' then ready_backend
114
+ when 'startup' then startup(record)
115
+ when 'terminal' then finish(record)
116
+ else raise ArgumentError, 'unknown Woods lifecycle event'
117
+ end
118
+ end
119
+
120
+ def validate_task_identity!(record)
121
+ raise ArgumentError, 'missing guardian hello' unless @guardian
122
+ raise ArgumentError, 'task event after terminal' if @terminal
123
+ raise ArgumentError, 'task identity mismatch' if @child_pid && @child_pid != record['pid']
124
+
125
+ @child_pid = record['pid']
126
+ end
127
+
128
+ def load_task(record)
129
+ version = record['woods_version']
130
+ unless !@task && version.is_a?(String) && Gem::Version.correct?(version)
131
+ raise ArgumentError, 'invalid task handshake'
132
+ end
133
+
134
+ @task = true
135
+ @state = 'booting'
136
+ end
137
+
138
+ def resolve_identity(record)
139
+ root, index = record.values_at('root', 'index')
140
+ unless @task && !@index && root.is_a?(String) && File.realpath(root) == @root &&
141
+ index.is_a?(String) && File.expand_path(index) == index
142
+ raise ArgumentError, 'invalid task root/index identity'
143
+ end
144
+
145
+ @index = index
146
+ @state = 'starting'
147
+ end
148
+
149
+ def ready_backend
150
+ raise ArgumentError, 'invalid backend ready event' unless booted? && !@backend
151
+
152
+ @backend = true
153
+ @state = 'reconciling'
154
+ end
155
+
156
+ def startup(record)
157
+ validate_startup!(record)
158
+ reconciled = record['reason'] == 'reconciled' && record['generation'].positive?
159
+ raise ArgumentError, 'inconsistent startup completion' unless (record['state'] == 'ready') == reconciled
160
+
161
+ @state = record['state']
162
+ @reason = record['reason']
163
+ end
164
+
165
+ def validate_startup!(record)
166
+ return if @backend && %w[ready degraded].include?(record['state']) &&
167
+ ManagedChild::STARTUP_REASONS.include?(record['reason']) &&
168
+ record['generation'].is_a?(Integer) && record['generation'] >= 0
169
+
170
+ raise ArgumentError, 'invalid startup completion'
171
+ end
172
+
173
+ def finish(record)
174
+ unless @task && ManagedChild::TERMINAL_REASONS.include?(record['reason'])
175
+ raise ArgumentError, 'invalid terminal event'
176
+ end
177
+
178
+ @terminal = record['reason']
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Woods
6
+ module Watch
7
+ # Private, bounded lifecycle reporting for a task owned by woods-watch.
8
+ # Raw rake tasks do not open descriptors or alter their lifecycle.
9
+ class ManagedChild
10
+ ENV_KEYS = %w[WOODS_WATCH_EVENT_FD WOODS_WATCH_LAUNCHER_TOKEN WOODS_WATCH_ATTEMPT_TOKEN].freeze
11
+ TOKEN = /\A[A-Za-z0-9_-]{1,128}\z/
12
+ MAX_BYTES = 4096
13
+ FIELDS = {
14
+ task_loaded: %i[woods_version], identity: %i[root index], backend_ready: [],
15
+ startup: %i[state generation reason], terminal: %i[reason]
16
+ }.freeze
17
+ STARTUP_REASONS = %w[reconciled startup_failed pending_work restart_required no_index catch_up_disabled].freeze
18
+ TERMINAL_REASONS = %w[restart_required already_running stopped idle unsupported_environment].freeze
19
+
20
+ # @param env [#[]] child process environment
21
+ # @return [ManagedChild, nil] nil for an ordinary unmanaged task
22
+ def self.from_env(env: ENV)
23
+ values = ENV_KEYS.map { |key| env[key] }
24
+ return if values.all?(&:nil?)
25
+
26
+ descriptor, launcher, attempt = values
27
+ validate_environment!(descriptor, [launcher, attempt])
28
+
29
+ unless env['WOODS_WATCH_IDLE_TIMEOUT'].to_s.empty?
30
+ raise ArgumentError, 'WOODS_WATCH_IDLE_TIMEOUT must be unset for managed watching'
31
+ end
32
+
33
+ new(IO.for_fd(descriptor.to_i, 'w'), launcher: launcher, attempt: attempt)
34
+ end
35
+
36
+ def self.validate_environment!(descriptor, tokens)
37
+ valid_tokens = tokens.all? { |token| token.is_a?(String) && token.match?(TOKEN) }
38
+ valid_fd = descriptor.is_a?(String) && descriptor.match?(/\A\d+\z/) && descriptor.to_i >= 3
39
+ return if valid_tokens && valid_fd
40
+
41
+ raise ArgumentError, 'invalid Woods managed-child environment'
42
+ end
43
+ private_class_method :validate_environment!
44
+
45
+ # @param io [IO] private event descriptor owned by this reporter
46
+ # @param launcher [String] supervisor identity token
47
+ # @param attempt [String] current child attempt identity token
48
+ def initialize(io, launcher:, attempt:)
49
+ @io = io
50
+ @io.binmode
51
+ @io.sync = true
52
+ @io.close_on_exec = true
53
+ @launcher = launcher
54
+ @attempt = attempt
55
+ @mutex = Mutex.new
56
+ end
57
+
58
+ # @param event [Symbol] an allowlisted lifecycle boundary
59
+ # @param fields [Hash] bounded protocol data, never application exceptions
60
+ # @return [void]
61
+ def call(event, **fields)
62
+ validate!(event, fields)
63
+ message = JSON.generate(version: 1, launcher: @launcher, attempt: @attempt,
64
+ event: event.to_s, pid: Process.pid, **fields) << "\n"
65
+ raise ArgumentError, 'Woods lifecycle message exceeds size limit' if message.bytesize > MAX_BYTES
66
+
67
+ @mutex.synchronize { @io.write(message) }
68
+ end
69
+
70
+ # @return [void]
71
+ def close
72
+ @mutex.synchronize { @io.close unless @io.closed? }
73
+ end
74
+
75
+ private
76
+
77
+ def validate!(event, fields)
78
+ expected = FIELDS[event]
79
+ raise ArgumentError, 'invalid Woods lifecycle event fields' unless expected && fields.keys.sort == expected.sort
80
+
81
+ validate_payload!(event, fields)
82
+ end
83
+
84
+ def validate_payload!(event, fields)
85
+ case event
86
+ when :startup then validate_startup!(fields)
87
+ when :terminal
88
+ raise ArgumentError, 'invalid Woods terminal reason' unless TERMINAL_REASONS.include?(fields[:reason])
89
+ when :identity
90
+ validate_identity!(fields)
91
+ when :task_loaded
92
+ raise ArgumentError, 'invalid Woods version' unless fields[:woods_version].is_a?(String)
93
+ end
94
+ end
95
+
96
+ def validate_identity!(fields)
97
+ return if %i[root index].all? do |key|
98
+ fields[key].is_a?(String) && fields[key] == File.expand_path(fields[key])
99
+ end
100
+
101
+ raise ArgumentError, 'Woods lifecycle identity must contain absolute paths'
102
+ end
103
+
104
+ def validate_startup!(fields)
105
+ return if %w[ready degraded].include?(fields[:state]) &&
106
+ fields[:generation].is_a?(Integer) && fields[:generation] >= 0 &&
107
+ STARTUP_REASONS.include?(fields[:reason])
108
+
109
+ raise ArgumentError, 'invalid Woods startup state or reason'
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Watch
5
+ # Bounded, idempotent cleanup of groups registered by an owned guardian.
6
+ module ManagedCleanup
7
+ private
8
+
9
+ def stop_owned
10
+ return if @stopped
11
+
12
+ @io_mutex.synchronize { @liveness.close if @liveness && !@liveness.closed? }
13
+ wait_until_stopped(@config[:shutdown_timeout] + 1)
14
+ force_stop if alive? || @status&.signaled?
15
+ @stopped = true
16
+ ensure
17
+ close
18
+ end
19
+
20
+ def force_stop
21
+ child = @stream&.child_pid
22
+ signal_owned_group('KILL', child) if child
23
+ signal_owned_group('KILL', @pid) if alive?
24
+ wait_until_stopped(1)
25
+ end
26
+
27
+ def wait_until_stopped(seconds)
28
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
29
+ while alive? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
30
+ drain_cleanup_events
31
+ sleep 0.02
32
+ end
33
+ end
34
+
35
+ def drain_cleanup_events
36
+ read_events
37
+ rescue ArgumentError
38
+ @io_mutex.synchronize { @reader.close unless @reader.closed? }
39
+ end
40
+
41
+ def signal_owned_group(signal, pid)
42
+ Process.kill(signal, -pid)
43
+ rescue Errno::ESRCH
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rbconfig'
5
+ require 'securerandom'
6
+ require_relative 'child_environment'
7
+ require_relative 'event_stream'
8
+ require_relative 'managed_cleanup'
9
+
10
+ module Woods
11
+ module Watch
12
+ # Owns one guardian and its private pipes. File-based PIDs never grant
13
+ # signal authority. The guardian starts before the application bundle.
14
+ class ManagedProcess
15
+ include ManagedCleanup
16
+
17
+ MAX_EVENT_BYTES = 4096
18
+
19
+ # @param command [Array<String>] explicit application argument vector
20
+ # @param root [String] application working directory
21
+ # @param env [Hash] application environment
22
+ # @param shutdown_timeout [Numeric] graceful shutdown seconds
23
+ # @param events [Boolean] expose the private task protocol to the child
24
+ # @param launcher [String] launcher identity
25
+ # @param attempt [String] fresh attempt identity
26
+ # rubocop:disable-next Metrics/ParameterLists -- the process and protocol identities are explicit collaborators
27
+ def initialize(command:, root:, env:, shutdown_timeout: 10, events: false,
28
+ launcher: SecureRandom.hex(16), attempt: SecureRandom.hex(16))
29
+ @config = { command: command, root: root, env: ChildEnvironment.build(ENV.to_h.merge(env), root: root),
30
+ shutdown_timeout: shutdown_timeout,
31
+ events: events, launcher: launcher, attempt: attempt }
32
+ @io_mutex = Mutex.new
33
+ @stop_mutex = Mutex.new
34
+ @pending_events = []
35
+ end
36
+
37
+ # @return [Integer, nil] owned guardian PID
38
+ attr_reader :pid
39
+
40
+ # @return [ManagedProcess] this started process
41
+ def start
42
+ raise ArgumentError, 'process already started' if @pid
43
+
44
+ parent, @liveness = IO.pipe
45
+ @reader, writer = IO.pipe
46
+ config_reader, config_writer = IO.pipe
47
+ spawn_guardian(parent, writer, config_reader)
48
+ @stream = EventStream.new(reader: @reader, guardian: @pid,
49
+ launcher: @config[:launcher], attempt: @config[:attempt])
50
+ [parent, writer, config_reader].each(&:close)
51
+ config_writer.write(JSON.generate(child_config))
52
+ config_writer.close
53
+ await_registration
54
+ self
55
+ rescue StandardError
56
+ [parent, writer, config_reader, config_writer].compact.each { |io| io.close unless io.closed? }
57
+ close
58
+ raise
59
+ end
60
+
61
+ # @return [Boolean] guardian still owns an active attempt
62
+ def alive?
63
+ return false unless @pid
64
+ return false if @status
65
+
66
+ @status = Process.waitpid2(@pid, Process::WNOHANG)&.last
67
+ @status.nil?
68
+ rescue Errno::ECHILD
69
+ false
70
+ end
71
+
72
+ # @return [Integer, nil] observed guardian exit code
73
+ def exit_status
74
+ alive?
75
+ @status&.exitstatus
76
+ end
77
+
78
+ # @return [Array<String>] bounded complete private protocol records
79
+ def read_events
80
+ @io_mutex.synchronize do
81
+ pending = @pending_events
82
+ @pending_events = []
83
+ pending + (@stream ? @stream.read : [])
84
+ end
85
+ end
86
+
87
+ # @return [Boolean] private stream ended
88
+ def eof?
89
+ @stream&.eof? == true
90
+ end
91
+
92
+ # Stop only this owned group, with cleanup delegated to its guardian.
93
+ # @return [void]
94
+ def stop
95
+ @stop_mutex.synchronize { stop_owned }
96
+ end
97
+
98
+ # Drop ownership; guardian observes EOF even if application boot hangs.
99
+ # @return [void]
100
+ def close
101
+ @io_mutex.synchronize do
102
+ [@liveness, @reader].compact.each { |io| io.close unless io.closed? }
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def await_registration
109
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
110
+ loop do
111
+ @pending_events.concat(@stream.read)
112
+ if @stream.child_pid
113
+ @liveness.write('S')
114
+ return
115
+ end
116
+ raise ArgumentError, 'guardian could not register its child' unless alive?
117
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
118
+ raise ArgumentError,
119
+ 'guardian registration timed out'
120
+ end
121
+
122
+ sleep 0.01
123
+ end
124
+ end
125
+
126
+ def spawn_guardian(parent, writer, config_reader)
127
+ guardian = File.expand_path('guardian.rb', __dir__)
128
+ @pid = Process.spawn({ 'RUBYOPT' => nil, 'RUBYLIB' => nil }, RbConfig.ruby, '--disable-gems', guardian,
129
+ 3 => writer, 4 => parent, 5 => config_reader, in: File::NULL,
130
+ pgroup: true, close_others: true)
131
+ end
132
+
133
+ def child_config
134
+ config = @config.dup
135
+ env = config[:env].dup
136
+ if config[:events]
137
+ env.merge!('WOODS_WATCH_EVENT_FD' => '3', 'WOODS_WATCH_LAUNCHER_TOKEN' => config[:launcher],
138
+ 'WOODS_WATCH_ATTEMPT_TOKEN' => config[:attempt])
139
+ end
140
+ config.merge(env: env, owner_pid: Process.pid)
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'puma_child'
4
+
5
+ module Woods
6
+ module Watch
7
+ # Optional Puma master-process integration. Requiring Woods itself never
8
+ # loads Puma or starts a watcher; only `plugin :woods` installs these hooks.
9
+ class PumaAdapter
10
+ SUPPORTED_MAJORS = [6, 7, 8].freeze
11
+ FALLBACK = 'Run bin/woods-watch through your development process manager instead.'
12
+
13
+ # @param launcher [Puma::Launcher] configured master launcher
14
+ # @param puma_version [String] loaded Puma version
15
+ # @param platform [String] Ruby platform used for supported-mode checks
16
+ def initialize(launcher, puma_version: ::Puma::Const::PUMA_VERSION, platform: RUBY_PLATFORM)
17
+ @launcher = launcher
18
+ @puma_version = puma_version
19
+ @platform = platform
20
+ @master_pid = Process.pid
21
+ # Puma has already chdir'd before plugin start. Expanding its raw
22
+ # relative `directory` option here would apply that directory twice.
23
+ @root = Dir.pwd
24
+ @started = false
25
+ @installed = false
26
+ end
27
+
28
+ # Register master-only lifecycle callbacks without starting extraction.
29
+ # @return [void]
30
+ def install
31
+ return if @installed
32
+
33
+ @installed = true
34
+ unless supported?
35
+ log("Puma #{@puma_version} on #{@platform} is unsupported by the Woods plugin. #{FALLBACK}")
36
+ return
37
+ end
38
+
39
+ hook(:after_booted, :on_booted) { start }
40
+ hook(:before_restart, :on_restart) { stop }
41
+ hook(:after_stopped, :on_stopped) { stop }
42
+ end
43
+
44
+ # Start at most once after Puma has finalized its environment and booted.
45
+ # @return [void]
46
+ def start
47
+ return unless Process.pid == @master_pid
48
+ return if @started || @launcher.options[:environment].to_s != 'development'
49
+
50
+ @started = true
51
+ unless File.file?(File.join(@root, 'bin/woods-watch'))
52
+ log('Missing bin/woods-watch; run bin/rails generate woods:watch --mode=puma. ' \
53
+ 'Automatic maintenance is inactive.')
54
+ return
55
+ end
56
+
57
+ @child = PumaChild.new(root: @root, environment: 'development', logger: @launcher.log_writer)
58
+ pid = @child.start
59
+ log("Started launcher #{pid}; index readiness is reported separately by woods-watch.")
60
+ rescue SystemCallError => e
61
+ log("Could not start launcher (#{e.class}); automatic maintenance is inactive. #{FALLBACK}")
62
+ end
63
+
64
+ # Stop only the launcher owned by this master process.
65
+ # @return [void]
66
+ def stop
67
+ @child&.stop if Process.pid == @master_pid
68
+ end
69
+
70
+ private
71
+
72
+ def supported?
73
+ SUPPORTED_MAJORS.include?(@puma_version.to_s.split('.').first.to_i) &&
74
+ Process.respond_to?(:fork) && !@platform.match?(/mswin|mingw|cygwin|java/)
75
+ end
76
+
77
+ def hook(current, legacy, &block)
78
+ events = @launcher.events
79
+ events.public_send(events.respond_to?(current) ? current : legacy, &block)
80
+ end
81
+
82
+ def log(message)
83
+ @launcher.log_writer.log("[woods-watch] #{message}")
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+ require_relative 'managed_process'
5
+
6
+ module Woods
7
+ module Watch
8
+ # Owns one foreground launcher. Retry policy belongs to that launcher, never
9
+ # to Puma; a failed launcher must not terminate or restart the web server.
10
+ class PumaChild
11
+ # The launcher gives its extraction child ten seconds to stop. Allow its
12
+ # guardian to finish that cleanup before escalating against the launcher.
13
+ STOP_GRACE = 15
14
+
15
+ # @param root [String] application root containing bin/woods-watch
16
+ # @param environment [String] finalized Puma environment
17
+ # @param logger [#log] Puma's log writer
18
+ # @param stop_grace [Numeric] graceful owned-process shutdown seconds
19
+ def initialize(root:, environment:, logger:, stop_grace: STOP_GRACE)
20
+ @root = root
21
+ @environment = environment
22
+ @logger = logger
23
+ @stop_grace = stop_grace
24
+ @stopping = false
25
+ end
26
+
27
+ # @return [Integer] owned guardian PID, not proof of index readiness
28
+ def start
29
+ @process = ManagedProcess.new(command: [RbConfig.ruby, File.join(@root, 'bin/woods-watch')],
30
+ root: @root, env: child_environment, shutdown_timeout: @stop_grace, events: false)
31
+ @process.start
32
+ @observer = Thread.new { observe }
33
+ @observer.name = 'woods-puma-launcher' if @observer.respond_to?(:name=)
34
+ @process.pid
35
+ end
36
+
37
+ # @return [void]
38
+ def stop
39
+ @stopping = true
40
+ # Puma can invoke stopped/restart callbacks from a signal trap.
41
+ # Only request and join here; the observer owns locks and IO cleanup.
42
+ (@observer || Thread.new { @process&.stop }).join
43
+ end
44
+
45
+ private
46
+
47
+ def child_environment
48
+ { 'APP_ENV' => @environment, 'RACK_ENV' => @environment, 'RAILS_ENV' => @environment }
49
+ end
50
+
51
+ def observe
52
+ until @stopping
53
+ unless @process.alive?
54
+ @process.stop
55
+ @logger.log('[woods-watch] Puma launcher stopped unexpectedly; automatic maintenance is inactive. ' \
56
+ 'Fix the launcher configuration and restart Puma, or run bin/woods-watch separately.')
57
+ return
58
+ end
59
+ sleep 0.1
60
+ end
61
+ ensure
62
+ @process.stop
63
+ end
64
+ end
65
+ end
66
+ end