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,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
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Watch
5
+ # Bounded readers and liveness correlation for persisted supervision records.
6
+ module SupervisionRecords
7
+ # Read bounded records without granting signal or writer-lock authority.
8
+ # @param index [String] published index root
9
+ # @return [Hash] supervision records with separately evaluated liveness
10
+ def self.read(index)
11
+ directory = File.join(index, SupervisionStatus::DIRECTORY)
12
+ return { records: [] } unless readable_directory?(directory)
13
+
14
+ names = Dir.each_child(directory).take(SupervisionStatus::MAX_SCAN + 1)
15
+ records = names.first(SupervisionStatus::MAX_SCAN).filter_map do |name|
16
+ read_record(File.join(directory, name)) if /\A[a-f0-9]{32}\.json\z/.match?(name)
17
+ end
18
+ records.sort_by! { |record| [record['alive'] ? 0 : 1, -Time.iso8601(record['updated_at']).to_f] }
19
+ correlate(records.first(SupervisionStatus::MAX_RECORDS), index)
20
+ truncated = names.size > SupervisionStatus::MAX_SCAN || records.size > SupervisionStatus::MAX_RECORDS
21
+ { records: records.first(SupervisionStatus::MAX_RECORDS), truncated: truncated }
22
+ rescue SystemCallError
23
+ { records: [] }
24
+ end
25
+
26
+ def self.readable_directory?(directory)
27
+ File.directory?(directory) && !File.symlink?(directory)
28
+ end
29
+ private_class_method :readable_directory?
30
+
31
+ def self.read_record(path)
32
+ bytes = bounded_read(path)
33
+ return unless bytes
34
+
35
+ record = JSON.parse(bytes)
36
+ return unless valid_record?(record, path)
37
+
38
+ age = Time.now - Time.iso8601(record['updated_at'])
39
+ record['alive'] = record['state'] != 'stopped' &&
40
+ age.between?(-30, SupervisionStatus::STALE_AFTER) && local_alive?(record)
41
+ record
42
+ rescue SystemCallError, JSON::ParserError, TypeError, ArgumentError
43
+ nil
44
+ end
45
+ private_class_method :read_record
46
+
47
+ def self.bounded_read(path)
48
+ return if File.symlink?(path)
49
+
50
+ flags = File::RDONLY | File::NONBLOCK
51
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
52
+ bytes = File.open(path, flags) { |file| file.read(SupervisionStatus::MAX_BYTES + 1) if file.stat.file? }
53
+ bytes if bytes && bytes.bytesize <= SupervisionStatus::MAX_BYTES
54
+ end
55
+ private_class_method :bounded_read
56
+
57
+ def self.valid_record?(record, path)
58
+ record.is_a?(Hash) && record['version'] == 1 && record['launcher'] == File.basename(path, '.json') &&
59
+ SupervisionStatus::STATES.include?(record['state'])
60
+ end
61
+ private_class_method :valid_record?
62
+
63
+ def self.local_alive?(record)
64
+ unless record['host'] == Status.host_identity && record['pid'].is_a?(Integer) && record['pid'].positive?
65
+ return false
66
+ end
67
+
68
+ Process.kill(0, record['pid'])
69
+ true
70
+ rescue Errno::ESRCH
71
+ false
72
+ rescue Errno::EPERM
73
+ true
74
+ end
75
+ private_class_method :local_alive?
76
+
77
+ def self.correlate(records, index)
78
+ status = Status.new(output_dir: index)
79
+ daemon = status.read
80
+ daemon_alive = daemon.is_a?(Hash) && status.alive?
81
+ records.each do |record|
82
+ record['active_child'] = daemon_alive && matches_child?(record, daemon)
83
+ end
84
+ rescue SystemCallError, TypeError, NoMethodError
85
+ records.each { |record| record['active_child'] = false }
86
+ end
87
+ private_class_method :correlate
88
+
89
+ def self.matches_child?(record, daemon)
90
+ record['alive'] && record['child_pid'] == daemon['pid'] && record['host'] == daemon['host']
91
+ end
92
+ private_class_method :matches_child?
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative 'status'
5
+ require_relative 'supervision_records'
6
+
7
+ module Woods
8
+ module Watch
9
+ # Independent owner records: a parked duplicate cannot overwrite the
10
+ # daemon heartbeat or make incremental hooks defer to an inactive writer.
11
+ class SupervisionStatus
12
+ DIRECTORY = 'watch_supervisors'
13
+ MAX_RECORDS = 32
14
+ MAX_SCAN = 256
15
+ MAX_BYTES = 8192
16
+ STALE_AFTER = 30
17
+ STATES = %w[starting booting reconciling ready degraded retrying parked stopped].freeze
18
+ FIELDS = %i[state reason attempt child_pid retry_at].freeze
19
+
20
+ # @param index [String] application-resolved output directory
21
+ # @param token [String] unguessable launcher identity
22
+ def initialize(index:, token:)
23
+ raise ArgumentError, 'invalid launcher token' unless /\A[a-f0-9]{32}\z/.match?(token)
24
+
25
+ @token = token
26
+ @directory = File.join(index, DIRECTORY)
27
+ @path = File.join(@directory, "#{token}.json")
28
+ end
29
+
30
+ # @param fields [Hash] state, owned child, attempt and bounded reason
31
+ # @return [void]
32
+ def write(**fields)
33
+ validate_fields!(fields)
34
+ verify_ownership!
35
+ data = { version: 1, launcher: @token, pid: Process.pid, host: Status.host_identity,
36
+ updated_at: Time.now.utc.iso8601 }.merge(fields)
37
+ bytes = JSON.generate(data)
38
+ raise ArgumentError, 'oversized supervision record' if bytes.bytesize > MAX_BYTES
39
+
40
+ AtomicFile.write(@path, bytes, mode: 0o644)
41
+ prune_stale
42
+ end
43
+
44
+ # @param index [String] published index root
45
+ # @return [Hash] bounded supervision diagnostics
46
+ def self.read(index)
47
+ SupervisionRecords.read(index)
48
+ end
49
+
50
+ def self.read_record(path)
51
+ SupervisionRecords.send(:read_record, path)
52
+ end
53
+ private_class_method :read_record
54
+
55
+ def self.local_alive?(record)
56
+ SupervisionRecords.send(:local_alive?, record)
57
+ end
58
+ private_class_method :local_alive?
59
+
60
+ private
61
+
62
+ def verify_ownership!
63
+ raise ArgumentError, 'invalid supervision directory' if File.symlink?(@directory)
64
+ return unless File.exist?(@path) || File.symlink?(@path)
65
+
66
+ previous = self.class.send(:read_record, @path)
67
+ return if previous && previous['pid'] == Process.pid && previous['host'] == Status.host_identity
68
+
69
+ raise ArgumentError, 'supervision record belongs to another owner'
70
+ end
71
+
72
+ def validate_fields!(fields)
73
+ valid = (fields.keys - FIELDS).empty? && STATES.include?(fields[:state]) &&
74
+ fields[:reason].is_a?(String) && /\A[a-z_]{1,80}\z/.match?(fields[:reason]) &&
75
+ fields[:attempt].is_a?(String) && /\A[a-f0-9]{32}\z/.match?(fields[:attempt])
76
+ raise ArgumentError, 'invalid supervision fields' unless valid
77
+ end
78
+
79
+ def prune_stale
80
+ records = self.class.read(File.dirname(@directory))[:records]
81
+ records.each do |record|
82
+ next unless expired?(record) && removable?(record)
83
+
84
+ path = File.join(@directory, "#{record['launcher']}.json")
85
+ current = self.class.send(:read_record, path)
86
+ stored = record.except('active_child')
87
+ File.unlink(path) if current == stored
88
+ end
89
+ rescue SystemCallError
90
+ nil
91
+ end
92
+
93
+ def expired?(record)
94
+ record['launcher'] != @token && !record['alive'] &&
95
+ Time.now - Time.iso8601(record['updated_at']) > STALE_AFTER
96
+ end
97
+
98
+ def removable?(record)
99
+ record['state'] == 'stopped' ||
100
+ (record['host'] == Status.host_identity && !self.class.send(:local_alive?, record))
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'managed_process'
4
+ require_relative 'lifecycle'
5
+ require_relative 'supervision_status'
6
+ require_relative 'supervisor_reporting'
7
+
8
+ module Woods
9
+ module Watch
10
+ # Foreground retry owner shared by Procfile and Puma installations.
11
+ # It never retries configuration conflicts or silently takes over ownership.
12
+ class Supervisor # rubocop:disable Metrics/ClassLength -- retry, park and shutdown share one foreground attempt state
13
+ include SupervisorReporting
14
+
15
+ RETRY_DELAYS = [1, 2, 4, 8, 16, 30].freeze
16
+
17
+ # @param command [Array<String>] fresh application invocation on each attempt
18
+ # @param root [String] application root
19
+ # @param env [Hash] application environment
20
+ # @param logger [#puts] diagnostic destination (stderr by default)
21
+ # @param boot_timeout [Numeric] pre-identity boot deadline
22
+ # @param shutdown_timeout [Numeric] graceful application shutdown
23
+ # @param retry_delays [Array<Numeric>] bounded retry schedule
24
+ # rubocop:disable-next Metrics/ParameterLists -- independently injectable process policy and diagnostics
25
+ def initialize(command:, root:, env: ENV.to_h, logger: $stderr, boot_timeout: 300,
26
+ shutdown_timeout: 10, retry_delays: RETRY_DELAYS)
27
+ raise ArgumentError, 'WOODS_WATCH_IDLE_TIMEOUT must be unset for managed watching' unless
28
+ env['WOODS_WATCH_IDLE_TIMEOUT'].to_s.empty?
29
+
30
+ @command = command
31
+ @root = File.realpath(root)
32
+ @env = env
33
+ @logger = logger
34
+ @boot_timeout = boot_timeout
35
+ @shutdown_timeout = shutdown_timeout
36
+ @retry_delays = retry_delays
37
+ @token = SecureRandom.hex(16)
38
+ @attempts = @failures = 0
39
+ @stopping = false
40
+ end
41
+
42
+ # @return [String, nil] honest lifecycle state
43
+ attr_reader :state, :attempts
44
+
45
+ # @return [Integer] zero after owner-requested shutdown
46
+ def run
47
+ until @stopping
48
+ run_attempt
49
+ break if @stopping
50
+
51
+ reason = retry_reason
52
+ reason ? retry_after(reason) : park(@protocol.terminal || 'incompatible_or_stopped_task')
53
+ end
54
+ 0
55
+ ensure
56
+ @process&.stop
57
+ publish('stopped', 'owner_stopped')
58
+ end
59
+
60
+ # Signal-safe request; process cleanup occurs on the run thread.
61
+ # @return [void]
62
+ def stop
63
+ @stopping = true
64
+ end
65
+
66
+ private
67
+
68
+ def run_attempt
69
+ prepare_attempt
70
+ @process.start
71
+ monitor_attempt
72
+ rescue ArgumentError => e
73
+ @invalid = true
74
+ @logger.puts("[woods-watch] incompatible lifecycle: #{e.message}")
75
+ ensure
76
+ @process&.stop
77
+ end
78
+
79
+ def prepare_attempt
80
+ retire_status
81
+ @attempts += 1
82
+ @attempt = SecureRandom.hex(16)
83
+ @invalid = @timed_out = false
84
+ @ready_at = nil
85
+ @started_at = monotonic
86
+ @protocol = Lifecycle.new(launcher: @token, attempt: @attempt, root: @root)
87
+ @process = ManagedProcess.new(command: @command, root: @root, env: @env, events: true,
88
+ launcher: @token, attempt: @attempt, shutdown_timeout: @shutdown_timeout)
89
+ publish('starting', 'boot_pending')
90
+ end
91
+
92
+ def monitor_attempt
93
+ loop do
94
+ consume_events
95
+ break if @stopping || @protocol.finished? || !@process.alive?
96
+
97
+ if !@protocol.booted? && monotonic - @started_at >= @boot_timeout
98
+ @timed_out = true
99
+ break
100
+ end
101
+ raise ArgumentError, 'lifecycle stream ended while task was running' if @process.eof?
102
+
103
+ heartbeat
104
+ sleep 0.05
105
+ end
106
+ consume_events
107
+ end
108
+
109
+ def consume_events
110
+ @process.read_events.each do |line|
111
+ record = @protocol.accept(line)
112
+ @status = SupervisionStatus.new(index: @protocol.index, token: @token) if record['event'] == 'identity'
113
+ track_readiness(record) if record['event'] == 'startup'
114
+ publish(@protocol.state, @protocol.reason) if @protocol.state && !%w[exit terminal].include?(record['event'])
115
+ end
116
+ end
117
+
118
+ def track_readiness(record)
119
+ @ready_at = record['state'] == 'ready' ? (@ready_at || monotonic) : nil
120
+ end
121
+
122
+ def retry_reason
123
+ return if parked_outcome?
124
+ return 'boot_timeout' if @timed_out
125
+ return 'restart_required' if @protocol.terminal == 'restart_required' && @protocol.exit_code == 75
126
+ return if [0, 127].include?(@protocol.exit_code)
127
+
128
+ 'child_failed'
129
+ end
130
+
131
+ def parked_outcome?
132
+ @invalid || %w[already_running unsupported_environment].include?(@protocol.terminal)
133
+ end
134
+
135
+ def retry_after(reason)
136
+ @failures = 0 if @ready_at && monotonic - @ready_at >= 60
137
+ delay = @retry_delays.fetch([@failures, @retry_delays.size - 1].min)
138
+ @failures += 1
139
+ publish('retrying', reason, retry_at: (Time.now + delay).utc.iso8601)
140
+ wait_until(monotonic + delay)
141
+ end
142
+
143
+ def park(reason)
144
+ publish('parked', reason)
145
+ @logger.puts('[woods-watch] restart the owner after correcting configuration; no automatic takeover')
146
+ wait_until(Float::INFINITY)
147
+ end
148
+
149
+ def wait_until(deadline)
150
+ until @stopping || monotonic >= deadline
151
+ heartbeat
152
+ sleep 0.05
153
+ end
154
+ end
155
+
156
+ def monotonic
157
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
158
+ end
159
+ end
160
+ end
161
+ end