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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +500 -420
- data/CONTRIBUTING.md +29 -17
- data/README.md +78 -178
- data/docs/AGENT_GUIDE.md +52 -11
- data/docs/AGENT_SETUP.md +34 -17
- data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
- data/docs/BACKEND_MATRIX.md +18 -7
- data/docs/CLIENT_HOOKS.md +1 -1
- data/docs/CONFIGURATION_REFERENCE.md +105 -29
- data/docs/CONSOLE_MCP_SETUP.md +54 -9
- data/docs/DOCKER_SETUP.md +16 -1
- data/docs/EVALUATION.md +10 -4
- data/docs/EXTRACTOR_REFERENCE.md +23 -3
- data/docs/FAQ.md +14 -3
- data/docs/GETTING_STARTED.md +18 -17
- data/docs/INCREMENTAL_EXTRACTION.md +37 -8
- data/docs/INDEX_LAYOUT.md +2 -2
- data/docs/MCP_SERVERS.md +79 -7
- data/docs/MCP_TOOL_COOKBOOK.md +5 -5
- data/docs/MCP_WORKTREE_SETUP.md +55 -83
- data/docs/PUBLISHED_INDEX.md +17 -0
- data/docs/README.md +2 -1
- data/docs/RETRIEVAL_GUIDE.md +81 -13
- data/docs/SOURCE_FRESHNESS.md +1 -1
- data/docs/TOKEN_BENCHMARK.md +16 -10
- data/docs/TROUBLESHOOTING.md +142 -47
- data/docs/UPGRADING_TO_2.md +12 -6
- data/docs/WATCH_DAEMON.md +189 -24
- data/docs/WHY_WOODS.md +9 -5
- data/exe/woods-console +13 -11
- data/exe/woods-mcp-start +14 -9
- data/exe/woods-watch +5 -0
- data/lib/generators/woods/pgvector_generator.rb +8 -2
- data/lib/generators/woods/watch_generator.rb +53 -0
- data/lib/puma/plugin/woods.rb +10 -0
- data/lib/tasks/woods.rake +14 -0
- data/lib/woods/agent_configuration/applier.rb +5 -3
- data/lib/woods/agent_configuration/cli.rb +2 -2
- data/lib/woods/agent_configuration/layout.rb +13 -0
- data/lib/woods/cache/cache_middleware.rb +6 -0
- data/lib/woods/console/credential_scanner.rb +4 -3
- data/lib/woods/console/dispatch_pipeline.rb +7 -0
- data/lib/woods/console/embedded_executor.rb +31 -9
- data/lib/woods/console/sql_noise_stripper.rb +9 -7
- data/lib/woods/console/sql_table_scanner.rb +47 -7
- data/lib/woods/console/sql_validator.rb +49 -9
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/console/stdio_transport.rb +27 -0
- data/lib/woods/coordination/pipeline_lock.rb +3 -2
- data/lib/woods/embedding/indexer.rb +24 -14
- data/lib/woods/extractor.rb +70 -19
- data/lib/woods/extractors/declared_parent.rb +55 -0
- data/lib/woods/extractors/graphql_extractor.rb +2 -11
- data/lib/woods/extractors/lib_extractor.rb +10 -8
- data/lib/woods/extractors/mailer_extractor.rb +6 -10
- data/lib/woods/extractors/model_extractor.rb +1 -15
- data/lib/woods/extractors/poro_extractor.rb +10 -8
- data/lib/woods/extractors/shared_utility_methods.rb +22 -5
- data/lib/woods/git_command.rb +6 -7
- data/lib/woods/git_provenance.rb +4 -6
- data/lib/woods/mcp/bearer_auth.rb +2 -1
- data/lib/woods/mcp/bootstrapper.rb +20 -5
- data/lib/woods/mcp/config_resolver.rb +2 -1
- data/lib/woods/mcp/index_reader.rb +11 -2
- data/lib/woods/mcp/initialization_guidance.rb +1 -1
- data/lib/woods/mcp/renderers/markdown_renderer.rb +14 -8
- data/lib/woods/mcp/renderers/plain_renderer.rb +11 -7
- data/lib/woods/mcp/server.rb +63 -37
- data/lib/woods/mcp/tool_contract.rb +1 -1
- data/lib/woods/mcp/tool_response_renderer.rb +16 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +1 -1
- data/lib/woods/mcp/traversal_response.rb +22 -0
- data/lib/woods/path_dispatcher.rb +6 -5
- data/lib/woods/published_index/typed_unit_reader.rb +40 -3
- data/lib/woods/published_index.rb +2 -2
- data/lib/woods/rake_helpers.rb +2 -12
- data/lib/woods/retrieval/corpus_status.rb +46 -0
- data/lib/woods/retrieval/lexical_assembler.rb +14 -3
- data/lib/woods/retrieval/lexical_index.rb +2 -1
- data/lib/woods/retriever.rb +19 -7
- data/lib/woods/session_tracer/file_store.rb +6 -1
- data/lib/woods/source_inputs/consumer_errors.rb +4 -0
- data/lib/woods/storage/local_corpus_stats.rb +32 -0
- data/lib/woods/storage/metadata_store.rb +20 -0
- data/lib/woods/storage/pgvector.rb +6 -2
- data/lib/woods/storage/vector_store.rb +10 -0
- data/lib/woods/temporal/json_snapshot_store.rb +35 -7
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/child_environment.rb +30 -0
- data/lib/woods/watch/cli.rb +91 -0
- data/lib/woods/watch/daemon.rb +73 -11
- data/lib/woods/watch/event_stream.rb +70 -0
- data/lib/woods/watch/guardian.rb +142 -0
- data/lib/woods/watch/installation/layout.rb +70 -0
- data/lib/woods/watch/installation/options.rb +128 -0
- data/lib/woods/watch/installation/planner.rb +128 -0
- data/lib/woods/watch/installation/probe.rb +101 -0
- data/lib/woods/watch/installation/receipt.rb +77 -0
- data/lib/woods/watch/installation/recovery.rb +64 -0
- data/lib/woods/watch/installation/templates.rb +58 -0
- data/lib/woods/watch/installation.rb +56 -0
- data/lib/woods/watch/lifecycle.rb +182 -0
- data/lib/woods/watch/managed_child.rb +113 -0
- data/lib/woods/watch/managed_cleanup.rb +48 -0
- data/lib/woods/watch/managed_process.rb +144 -0
- data/lib/woods/watch/puma_adapter.rb +87 -0
- data/lib/woods/watch/puma_child.rb +66 -0
- data/lib/woods/watch/supervision_records.rb +95 -0
- data/lib/woods/watch/supervision_status.rb +104 -0
- data/lib/woods/watch/supervisor.rb +161 -0
- data/lib/woods/watch/supervisor_reporting.rb +46 -0
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/woods-input-rules.sh +4 -4
- data/plugin/skills/woods-agent-enable/SKILL.md +7 -1
- data/plugin/skills/woods-diagnose/SKILL.md +134 -34
- data/plugin/skills/woods-investigate/SKILL.md +54 -15
- data/plugin/skills/woods-mcp-config/SKILL.md +38 -11
- data/plugin/skills/woods-setup/SKILL.md +72 -15
- 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
|