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,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Watch
|
|
7
|
+
class Installation
|
|
8
|
+
# Validates portable ownership before any current file is changed.
|
|
9
|
+
class Receipt
|
|
10
|
+
attr_reader :data
|
|
11
|
+
|
|
12
|
+
# @param document [Woods::AgentConfiguration::Document] portable receipt
|
|
13
|
+
def initialize(document)
|
|
14
|
+
@data = document.content && document.json
|
|
15
|
+
validate! if data
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Array<String>] portable paths recorded by this installation
|
|
19
|
+
def paths
|
|
20
|
+
return [] unless data
|
|
21
|
+
|
|
22
|
+
data.fetch('files').keys + data.fetch('sections').keys
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param document [Woods::AgentConfiguration::Document] owned file
|
|
26
|
+
# @param expected [Hash] prior content fingerprint and mode
|
|
27
|
+
# @return [void]
|
|
28
|
+
def self.verify_file!(document, expected)
|
|
29
|
+
return if document.fingerprint == expected
|
|
30
|
+
|
|
31
|
+
raise Conflict, "Owned watcher file was edited or removed: #{document.path}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validate!
|
|
37
|
+
unless data['schema_version'] == 1 && data['selection'].is_a?(Hash) &&
|
|
38
|
+
%w[procfile puma external].include?(data.dig('selection', 'mode')) &&
|
|
39
|
+
data['files'].is_a?(Hash) && data['sections'].is_a?(Hash)
|
|
40
|
+
raise Conflict, 'Malformed watcher installation receipt; restore its owned metadata'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
validate_files!
|
|
44
|
+
validate_sections!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_files!
|
|
48
|
+
data.fetch('files').each do |path, record|
|
|
49
|
+
next if path == Layout::WRAPPER && valid_file_record?(record)
|
|
50
|
+
|
|
51
|
+
raise Conflict, 'Invalid owned watcher executable in receipt'
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_sections!
|
|
56
|
+
data.fetch('sections').each do |path, record|
|
|
57
|
+
valid_path = path == Layout::PUMA || Layout::PROCFILE.match?(path)
|
|
58
|
+
next if valid_path && valid_section_record?(record)
|
|
59
|
+
|
|
60
|
+
raise Conflict, 'Invalid owned watcher section in receipt'
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def valid_file_record?(record)
|
|
65
|
+
record.is_a?(Hash) && record['exists'] == true && record['mode'] == 0o755 &&
|
|
66
|
+
record['sha256'].is_a?(String) && record['sha256'].match?(/\A[0-9a-f]{64}\z/)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def valid_section_record?(record)
|
|
70
|
+
record.is_a?(Hash) && [true, false].include?(record['created_file']) &&
|
|
71
|
+
record['owned_text'].is_a?(String) && record['owned_text'].include?(Templates::START) &&
|
|
72
|
+
record['owned_text'].include?(Templates::FINISH)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Watch
|
|
5
|
+
class Installation
|
|
6
|
+
# Replays only validated local transaction state; never discovers or boots Rails.
|
|
7
|
+
class Recovery
|
|
8
|
+
# @param root [String] selected canonical application directory
|
|
9
|
+
def initialize(root:)
|
|
10
|
+
@root = root
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @param pretend [Boolean] check journal and current snapshots without writes
|
|
14
|
+
# @return [String] recovery status
|
|
15
|
+
def call(pretend: false)
|
|
16
|
+
journal = read_journal
|
|
17
|
+
return 'nothing_to_recover' unless journal
|
|
18
|
+
|
|
19
|
+
layout = recovery_layout(journal)
|
|
20
|
+
applier = Woods::AgentConfiguration::Applier.new(layout: layout)
|
|
21
|
+
return applier.recover unless pretend
|
|
22
|
+
|
|
23
|
+
verify_preview(applier, layout, journal)
|
|
24
|
+
'recovery_preview_verified'
|
|
25
|
+
rescue KeyError, TypeError, ArgumentError => e
|
|
26
|
+
raise Conflict, "Invalid watcher recovery journal: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def read_journal
|
|
32
|
+
layout = Layout.new(@root, 'Procfile.dev')
|
|
33
|
+
document = Woods::AgentConfiguration::Document.new("#{layout.receipt_path}.pending")
|
|
34
|
+
document.content && document.json
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def recovery_layout(journal)
|
|
38
|
+
plan = journal.fetch('plan')
|
|
39
|
+
identity = plan.fetch('layout')
|
|
40
|
+
raise Conflict, 'Recovery journal belongs to another application' unless identity.fetch('root') == @root
|
|
41
|
+
|
|
42
|
+
layout = Layout.new(@root, identity.fetch('procfile'))
|
|
43
|
+
paths = plan.fetch('changes').map do |change|
|
|
44
|
+
path = change.fetch('path')
|
|
45
|
+
raise Conflict, 'Recovery target is outside the application' unless path.start_with?("#{@root}/")
|
|
46
|
+
|
|
47
|
+
path.delete_prefix("#{@root}/")
|
|
48
|
+
end
|
|
49
|
+
layout.include_previous(paths)
|
|
50
|
+
layout
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def verify_preview(applier, layout, journal)
|
|
54
|
+
plan = Woods::AgentConfiguration::Plan.allocate
|
|
55
|
+
plan.instance_variable_set(:@data, journal.fetch('plan'))
|
|
56
|
+
plan.validate!(layout)
|
|
57
|
+
changes = plan.data.fetch('changes')
|
|
58
|
+
applier.send(:validate_originals!, journal.fetch('originals'), changes)
|
|
59
|
+
changes.each { |change| applier.send(:validate_recovery_snapshot!, change) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Watch
|
|
5
|
+
class Installation
|
|
6
|
+
# Small generated fragments; runtime identities never enter committed files.
|
|
7
|
+
module Templates
|
|
8
|
+
START = '# woods:watch:managed:start'
|
|
9
|
+
FINISH = '# woods:watch:managed:end'
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @param child_command [Array<String>] explicit task argv
|
|
14
|
+
# @return [String] application-relative executable source
|
|
15
|
+
def wrapper(child_command)
|
|
16
|
+
<<~RUBY
|
|
17
|
+
#!/usr/bin/env ruby
|
|
18
|
+
# frozen_string_literal: true
|
|
19
|
+
|
|
20
|
+
# Generated by Woods; use woods:watch --operation update to change owned setup.
|
|
21
|
+
root = File.expand_path('..', __dir__)
|
|
22
|
+
ENV['BUNDLE_GEMFILE'] ||= File.join(root, 'Gemfile')
|
|
23
|
+
require 'bundler/setup'
|
|
24
|
+
Dir.chdir(root)
|
|
25
|
+
exec Gem.ruby, Gem.bin_path('woods', 'woods-watch'), '--root', root,
|
|
26
|
+
*ARGV, '--', *#{child_command.inspect}
|
|
27
|
+
RUBY
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param mode [String] procfile or puma
|
|
31
|
+
# @return [String] owned startup directive
|
|
32
|
+
def directive(mode)
|
|
33
|
+
return 'woods: bin/woods-watch' unless mode == 'puma'
|
|
34
|
+
|
|
35
|
+
'plugin :woods if Gem.loaded_specs["woods"]&.full_require_paths&.any? ' \
|
|
36
|
+
'{ |path| File.file?(File.join(path, "puma/plugin/woods.rb")) }'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param mode [String] selected startup mode
|
|
40
|
+
# @param text [String] surrounding file contents
|
|
41
|
+
# @param previous [String, nil] existing receipt-owned block
|
|
42
|
+
# @param update [Boolean] refresh an existing block in place
|
|
43
|
+
# @return [String] exact appended owned block including separator
|
|
44
|
+
def section(mode, text, previous: nil, update: false)
|
|
45
|
+
if previous
|
|
46
|
+
return previous unless update
|
|
47
|
+
|
|
48
|
+
return previous.partition(START).first + section(mode, previous)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
newline = text.include?("\r\n") ? "\r\n" : "\n"
|
|
52
|
+
separator = text.empty? || text.end_with?(newline) ? '' : newline
|
|
53
|
+
separator + [START, directive(mode), FINISH, ''].join(newline)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../agent_configuration/applier'
|
|
5
|
+
require_relative '../agent_configuration/plan'
|
|
6
|
+
require_relative 'installation/layout'
|
|
7
|
+
require_relative 'installation/options'
|
|
8
|
+
require_relative 'installation/planner'
|
|
9
|
+
require_relative 'installation/probe'
|
|
10
|
+
require_relative 'installation/recovery'
|
|
11
|
+
|
|
12
|
+
module Woods
|
|
13
|
+
module Watch
|
|
14
|
+
# Plans and applies portable, explicitly owned watcher startup configuration.
|
|
15
|
+
class Installation
|
|
16
|
+
Conflict = Woods::AgentConfiguration::Conflict
|
|
17
|
+
|
|
18
|
+
# @param root [String] application directory
|
|
19
|
+
# @param options [Hash] mode, operation, child/manager argv and preflight options
|
|
20
|
+
def initialize(root:, **options)
|
|
21
|
+
@options = Options.new(root: root, **options)
|
|
22
|
+
@layout = Layout.new(@options.root, @options.procfile)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Preview all edits without creating files or starting a watcher.
|
|
26
|
+
# @return [Woods::AgentConfiguration::Plan]
|
|
27
|
+
def plan
|
|
28
|
+
@options.validate!
|
|
29
|
+
Planner.new(layout: @layout, options: @options).call
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Apply a fresh or previously reviewed plan using conflict-checked writes.
|
|
33
|
+
# @param reviewed_plan [Woods::AgentConfiguration::Plan, nil] optional preview
|
|
34
|
+
# @return [String] applied or already_applied
|
|
35
|
+
def apply(reviewed_plan = nil)
|
|
36
|
+
selected = reviewed_plan || plan
|
|
37
|
+
return 'already_applied' if selected.data.fetch('changes').empty?
|
|
38
|
+
|
|
39
|
+
Woods::AgentConfiguration::Applier.new(layout: @layout).apply(selected)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Explain which process must own this installation.
|
|
43
|
+
# @return [String]
|
|
44
|
+
def handoff
|
|
45
|
+
@options.handoff
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Recover an interrupted transaction without booting the application.
|
|
49
|
+
# @param pretend [Boolean] validate the journal without restoring files
|
|
50
|
+
# @return [String] recovery outcome
|
|
51
|
+
def recover(pretend: false)
|
|
52
|
+
Recovery.new(root: @options.root).call(pretend: pretend)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -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
|