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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +495 -471
- data/CONTRIBUTING.md +12 -2
- data/README.md +11 -26
- data/docs/AGENT_GUIDE.md +31 -12
- data/docs/AGENT_SETUP.md +17 -10
- data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
- data/docs/BACKEND_MATRIX.md +13 -7
- data/docs/CLIENT_HOOKS.md +1 -1
- data/docs/CONFIGURATION_REFERENCE.md +44 -27
- data/docs/CONSOLE_MCP_SETUP.md +95 -16
- data/docs/DOCKER_SETUP.md +15 -0
- data/docs/EVALUATION.md +10 -4
- data/docs/EXTRACTOR_REFERENCE.md +14 -2
- data/docs/FAQ.md +14 -3
- data/docs/GETTING_STARTED.md +18 -17
- data/docs/INCREMENTAL_EXTRACTION.md +8 -3
- data/docs/INDEX_LAYOUT.md +2 -2
- data/docs/MCP_HTTP_TRANSPORT.md +54 -2
- data/docs/MCP_SERVERS.md +28 -11
- data/docs/MCP_TOOL_COOKBOOK.md +1 -1
- data/docs/MCP_WORKTREE_SETUP.md +13 -1
- data/docs/PUBLISHED_INDEX.md +1 -1
- data/docs/README.md +2 -1
- data/docs/RETRIEVAL_GUIDE.md +57 -8
- data/docs/SOURCE_FRESHNESS.md +1 -1
- data/docs/TOKEN_BENCHMARK.md +16 -10
- data/docs/TROUBLESHOOTING.md +133 -37
- data/docs/UPGRADING_TO_2.md +69 -7
- data/docs/WATCH_DAEMON.md +172 -17
- data/docs/WHY_WOODS.md +9 -5
- data/exe/woods-console +13 -11
- data/exe/woods-mcp-http +16 -9
- data/exe/woods-watch +5 -0
- 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/cache/cache_middleware.rb +18 -11
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/embedded_executor.rb +401 -43
- data/lib/woods/console/model_validator.rb +8 -0
- data/lib/woods/console/rack_middleware.rb +39 -10
- data/lib/woods/console/redactor.rb +24 -10
- data/lib/woods/console/safe_context.rb +44 -7
- data/lib/woods/console/sql_noise_stripper.rb +41 -12
- data/lib/woods/console/sql_table_scanner.rb +45 -34
- data/lib/woods/console/sql_validator.rb +37 -2
- data/lib/woods/console/stdio_transport.rb +27 -0
- data/lib/woods/extractor.rb +25 -7
- data/lib/woods/git_command.rb +6 -7
- data/lib/woods/git_provenance.rb +4 -6
- data/lib/woods/mcp/bearer_auth.rb +1 -1
- data/lib/woods/mcp/bootstrapper.rb +3 -1
- data/lib/woods/mcp/initialization_guidance.rb +1 -1
- data/lib/woods/mcp/origin_guard.rb +24 -77
- data/lib/woods/mcp/origin_policy.rb +124 -0
- data/lib/woods/mcp/server.rb +41 -9
- data/lib/woods/railtie_support.rb +8 -0
- data/lib/woods/retrieval/corpus_status.rb +46 -0
- data/lib/woods/retriever.rb +19 -7
- data/lib/woods/storage/local_corpus_stats.rb +32 -0
- data/lib/woods/storage/metadata_store.rb +20 -0
- data/lib/woods/storage/vector_store.rb +10 -0
- 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 +55 -7
- 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/skills/woods-agent-enable/SKILL.md +1 -1
- data/plugin/skills/woods-diagnose/SKILL.md +88 -8
- data/plugin/skills/woods-investigate/SKILL.md +6 -6
- data/plugin/skills/woods-mcp-config/SKILL.md +43 -1
- data/plugin/skills/woods-setup/SKILL.md +66 -4
- metadata +37 -5
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative '../../agent_configuration/document'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Watch
|
|
8
|
+
class Installation
|
|
9
|
+
# Absolute paths are transient apply state; receipts retain only relative paths.
|
|
10
|
+
class Layout
|
|
11
|
+
RECEIPT = '.woods-watch.json'
|
|
12
|
+
WRAPPER = 'bin/woods-watch'
|
|
13
|
+
PUMA = 'config/puma.rb'
|
|
14
|
+
PROCFILE = /\AProcfile(?:\.[a-zA-Z0-9_-]+)?\z/
|
|
15
|
+
|
|
16
|
+
attr_reader :root
|
|
17
|
+
|
|
18
|
+
# @param root [String] canonical application root
|
|
19
|
+
# @param procfile [String] selected root-level Procfile
|
|
20
|
+
def initialize(root, procfile)
|
|
21
|
+
@root = root
|
|
22
|
+
@procfile = procfile
|
|
23
|
+
@extra_paths = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param path [String] portable target path
|
|
27
|
+
# @return [String] checked absolute path
|
|
28
|
+
def absolute(path)
|
|
29
|
+
unless [RECEIPT, WRAPPER, PUMA].include?(path) || (path.is_a?(String) && PROCFILE.match?(path))
|
|
30
|
+
raise Conflict, "Unsupported watcher installation target: #{path.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
File.join(root, path)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param paths [Array<String>] prior owned paths read from the receipt
|
|
37
|
+
# @return [void]
|
|
38
|
+
def include_previous(paths)
|
|
39
|
+
@extra_paths = paths.map { |path| absolute(path) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Array<String>] exact allowed write set
|
|
43
|
+
def allowed_paths
|
|
44
|
+
([RECEIPT, WRAPPER, PUMA, @procfile].map { |path| absolute(path) } + @extra_paths).uniq
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String] local journal stem, separate from the portable receipt
|
|
48
|
+
def receipt_path
|
|
49
|
+
File.join(root, 'tmp', 'woods-watch-install', 'transaction')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Array<String>] local serialization lock
|
|
53
|
+
def lock_paths
|
|
54
|
+
["#{receipt_path}.lock"]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Hash] transient preview identity, never committed to the receipt
|
|
58
|
+
def identity
|
|
59
|
+
{ 'kind' => 'woods-watch-installation', 'root' => root, 'procfile' => @procfile }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param path [String] portable target path
|
|
63
|
+
# @return [Woods::AgentConfiguration::Document]
|
|
64
|
+
def document(path)
|
|
65
|
+
Woods::AgentConfiguration::Document.new(absolute(path))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Watch
|
|
7
|
+
class Installation
|
|
8
|
+
# Restricts automatic edits to explicit, verified startup arrangements.
|
|
9
|
+
class Options
|
|
10
|
+
attr_reader :root, :mode, :operation, :child_command, :manager_command, :procfile
|
|
11
|
+
|
|
12
|
+
# @param root [String] application root
|
|
13
|
+
# @param options [Hash] installation choices and injectable probe/environment
|
|
14
|
+
def initialize(root:, **options)
|
|
15
|
+
@root = File.realpath(root)
|
|
16
|
+
@mode = options.fetch(:mode, nil)
|
|
17
|
+
@operation = options.fetch(:operation, 'setup')
|
|
18
|
+
@child_command = argv(options.fetch(:child_command, %w[bin/rails woods:watch]))
|
|
19
|
+
@manager_command = options[:manager_command] && argv(options[:manager_command])
|
|
20
|
+
@procfile = options.fetch(:procfile, 'Procfile.dev')
|
|
21
|
+
@environment = options.fetch(:environment, ENV)
|
|
22
|
+
@probe = options.fetch(:probe) { Probe.new(environment: @environment) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [void]
|
|
26
|
+
def validate!
|
|
27
|
+
validate_selection!
|
|
28
|
+
raise Conflict, 'Select a root-level Procfile explicitly' unless Layout::PROCFILE.match?(procfile.to_s)
|
|
29
|
+
return if operation == 'remove'
|
|
30
|
+
|
|
31
|
+
raise Conflict, 'Application Gemfile is missing' unless File.file?(File.join(root, 'Gemfile'))
|
|
32
|
+
|
|
33
|
+
validate_child!
|
|
34
|
+
validate_idle!
|
|
35
|
+
validate_manager! if mode == 'procfile'
|
|
36
|
+
validate_puma! if mode == 'puma'
|
|
37
|
+
@probe.call(root: root, child_command: child_command,
|
|
38
|
+
manager_command: mode == 'procfile' ? manager_command : nil, puma: mode == 'puma')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Hash] portable selection persisted in the receipt
|
|
42
|
+
def selection
|
|
43
|
+
{ 'mode' => mode, 'child_command' => child_command,
|
|
44
|
+
'manager_command' => mode == 'procfile' ? manager_command : nil,
|
|
45
|
+
'procfile' => mode == 'procfile' ? procfile : nil }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [String] truthful startup handoff, separate from validation evidence
|
|
49
|
+
def handoff
|
|
50
|
+
case mode
|
|
51
|
+
when 'external'
|
|
52
|
+
"Configure one external supervisor to run: #{Shellwords.join(child_command)}; retain raw exit-75 recovery."
|
|
53
|
+
when 'procfile'
|
|
54
|
+
"Start with #{Shellwords.join(manager_command)}. bin/dev was preserved; verify catch-up and an edit."
|
|
55
|
+
else
|
|
56
|
+
'Start with bin/rails server using config/puma.rb; custom -C startup is unsupported. ' \
|
|
57
|
+
'Verify catch-up and an edit; other environments start no watcher.'
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def argv(value)
|
|
64
|
+
items = value.is_a?(String) ? Shellwords.split(value) : value
|
|
65
|
+
unless items.is_a?(Array) && !items.empty? && items.all? { |arg| valid_argument?(arg) }
|
|
66
|
+
raise Conflict, 'Commands must be nonempty argument vectors, not shell scripts'
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
items
|
|
70
|
+
rescue ArgumentError => e
|
|
71
|
+
raise Conflict, "Invalid command arguments: #{e.message}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def valid_argument?(argument)
|
|
75
|
+
argument.is_a?(String) && !argument.empty? && !argument.include?("\0")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_selection!
|
|
79
|
+
unless %w[setup update remove].include?(operation)
|
|
80
|
+
raise Conflict, 'Supported operations: setup, update, remove'
|
|
81
|
+
end
|
|
82
|
+
return if operation == 'remove' || %w[procfile puma external].include?(mode)
|
|
83
|
+
|
|
84
|
+
raise Conflict, 'Select mode procfile, puma, or external explicitly'
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validate_child!
|
|
88
|
+
return if child_command.last == 'woods:watch'
|
|
89
|
+
|
|
90
|
+
raise Conflict, 'Child command must end with woods:watch so preflight can verify the installed task with -T'
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_idle!
|
|
94
|
+
value = @environment['WOODS_WATCH_IDLE_TIMEOUT']
|
|
95
|
+
return if mode == 'external' || value.nil? || value.empty?
|
|
96
|
+
|
|
97
|
+
raise Conflict, 'Unset WOODS_WATCH_IDLE_TIMEOUT; managed maintenance needs a resident child'
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_manager!
|
|
101
|
+
unless valid_manager?
|
|
102
|
+
raise Conflict, 'Select verified Foreman startup with -f matching the Procfile; ' \
|
|
103
|
+
'plain Rails bin/dev needs Puma mode'
|
|
104
|
+
end
|
|
105
|
+
document = Woods::AgentConfiguration::Document.new(File.join(root, procfile))
|
|
106
|
+
raise Conflict, "Selected Procfile does not exist: #{procfile}" unless document.content
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_puma!
|
|
110
|
+
return unless File.exist?(File.join(root, 'config/puma/development.rb'))
|
|
111
|
+
|
|
112
|
+
raise Conflict, 'Puma selects config/puma/development.rb before config/puma.rb; ' \
|
|
113
|
+
'use external/Foreman mode for custom Puma configuration'
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def valid_manager?
|
|
117
|
+
args = manager_command
|
|
118
|
+
return false unless args
|
|
119
|
+
|
|
120
|
+
args = args.drop(2) if args.first(2) == %w[bundle exec]
|
|
121
|
+
return false unless args.length == 4 && File.basename(args[0]) == 'foreman'
|
|
122
|
+
|
|
123
|
+
args[1] == 'start' && %w[-f --procfile].include?(args[2]) && args[3] == procfile
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'templates'
|
|
4
|
+
require_relative 'receipt'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Watch
|
|
8
|
+
class Installation
|
|
9
|
+
# Builds one conflict-checked transaction while preserving unowned bytes.
|
|
10
|
+
class Planner
|
|
11
|
+
# @param layout [Layout] destination and local transaction paths
|
|
12
|
+
# @param options [Options] preflight-validated selections
|
|
13
|
+
def initialize(layout:, options:)
|
|
14
|
+
@layout = layout
|
|
15
|
+
@options = options
|
|
16
|
+
@documents = {}
|
|
17
|
+
@contents = {}
|
|
18
|
+
@modes = {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Woods::AgentConfiguration::Plan] complete preview
|
|
22
|
+
def call
|
|
23
|
+
@receipt = Receipt.new(document(Layout::RECEIPT))
|
|
24
|
+
@layout.include_previous(@receipt.paths)
|
|
25
|
+
@plan = Woods::AgentConfiguration::Plan.new(layout: @layout, operation: @options.operation)
|
|
26
|
+
validate_operation!
|
|
27
|
+
remove_previous
|
|
28
|
+
install unless @options.operation == 'remove'
|
|
29
|
+
publish_plan
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def validate_operation!
|
|
35
|
+
previous = @receipt.data
|
|
36
|
+
if @options.operation == 'update' && !previous
|
|
37
|
+
raise Conflict, 'No owned watcher installation to update; run setup first'
|
|
38
|
+
end
|
|
39
|
+
return unless previous && @options.operation == 'setup' && previous.fetch('selection') != @options.selection
|
|
40
|
+
|
|
41
|
+
raise Conflict, 'Watcher setup selection changed; use --operation update to switch modes or commands'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def document(path)
|
|
45
|
+
@documents[path] ||= @layout.document(path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def content(path)
|
|
49
|
+
@contents.key?(path) ? @contents[path] : document(path).content
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def remove_previous
|
|
53
|
+
return unless @receipt.data
|
|
54
|
+
|
|
55
|
+
@receipt.data.fetch('files').each do |path, expected|
|
|
56
|
+
Receipt.verify_file!(document(path), expected)
|
|
57
|
+
@contents[path] = nil
|
|
58
|
+
end
|
|
59
|
+
@receipt.data.fetch('sections').each { |path, record| remove_section(path, record) }
|
|
60
|
+
@contents[Layout::RECEIPT] = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def remove_section(path, record)
|
|
64
|
+
text = document(path).content || ''
|
|
65
|
+
block = record.fetch('owned_text')
|
|
66
|
+
unless text.scan(Templates::START).size == 1 && text.scan(Templates::FINISH).size == 1 && text.include?(block)
|
|
67
|
+
raise Conflict, "Owned watcher section was edited or removed: #{path}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
remaining = text.sub(block, '')
|
|
71
|
+
@contents[path] = record.fetch('created_file') && remaining.empty? ? nil : remaining
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def install
|
|
75
|
+
@new_receipt = { 'schema_version' => 1, 'selection' => @options.selection, 'files' => {}, 'sections' => {} }
|
|
76
|
+
if @options.mode != 'external'
|
|
77
|
+
install_wrapper
|
|
78
|
+
install_section
|
|
79
|
+
end
|
|
80
|
+
@contents[Layout::RECEIPT] = "#{JSON.pretty_generate(@new_receipt)}\n"
|
|
81
|
+
@modes[Layout::RECEIPT] = 0o644
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def install_wrapper
|
|
85
|
+
path = Layout::WRAPPER
|
|
86
|
+
raise Conflict, 'Refusing to overwrite an unowned bin/woods-watch' if content(path)
|
|
87
|
+
|
|
88
|
+
text = Templates.wrapper(@options.child_command)
|
|
89
|
+
@contents[path] = text
|
|
90
|
+
@modes[path] = 0o755
|
|
91
|
+
@new_receipt.fetch('files')[path] = { 'sha256' => Digest::SHA256.hexdigest(text), 'mode' => 0o755, 'exists' => true }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def install_section
|
|
95
|
+
path = @options.mode == 'puma' ? Layout::PUMA : @options.procfile
|
|
96
|
+
text = content(path) || ''
|
|
97
|
+
reject_unowned_section!(text)
|
|
98
|
+
previous = @receipt.data&.dig('sections', path, 'owned_text')
|
|
99
|
+
block = Templates.section(@options.mode, text, previous: previous, update: @options.operation == 'update')
|
|
100
|
+
@contents[path] = previous ? document(path).content.sub(previous, block) : text + block
|
|
101
|
+
@modes[path] = document(path).content ? document(path).mode : 0o644
|
|
102
|
+
@new_receipt.fetch('sections')[path] = { 'owned_text' => block, 'created_file' => content_created?(path) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def content_created?(path)
|
|
106
|
+
@receipt.data&.dig('sections', path, 'created_file') || document(path).content.nil?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def reject_unowned_section!(text)
|
|
110
|
+
conflict = text.include?(Templates::START) || text.include?(Templates::FINISH)
|
|
111
|
+
pattern = @options.mode == 'puma' ? /^\s*plugin(?:\s+|\s*\(\s*)[:'"]woods\b/ : /^\s*woods\s*:/
|
|
112
|
+
conflict ||= text.match?(pattern)
|
|
113
|
+
raise Conflict, 'Unowned Woods startup directive or managed markers already exist' if conflict
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def publish_plan
|
|
117
|
+
@contents.each do |path, text|
|
|
118
|
+
current = document(path)
|
|
119
|
+
@plan.add(current, text, description: "#{@options.operation} Woods watcher ownership in #{path}")
|
|
120
|
+
change = @plan.data.fetch('changes').find { |entry| entry['path'] == current.path }
|
|
121
|
+
change['mode'] = @modes.fetch(path, current.mode) if change && text
|
|
122
|
+
end
|
|
123
|
+
@plan
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
require_relative '../child_environment'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module Watch
|
|
9
|
+
class Installation
|
|
10
|
+
# Read-only, bounded task and manager checks; never starts the watch task.
|
|
11
|
+
class Probe
|
|
12
|
+
PUMA_CHECK = <<~RUBY
|
|
13
|
+
require 'puma'
|
|
14
|
+
version = Gem.loaded_specs.fetch('puma').version
|
|
15
|
+
supported = version >= Gem::Version.new('6') && version < Gem::Version.new('9') &&
|
|
16
|
+
!RUBY_PLATFORM.match?(/mswin|mingw|cygwin|java/)
|
|
17
|
+
abort 'Supported Puma 6/7/8 on a native Unix Ruby is required; use external supervision' unless supported
|
|
18
|
+
puts version
|
|
19
|
+
RUBY
|
|
20
|
+
|
|
21
|
+
# @param environment [Hash] selected application environment
|
|
22
|
+
# @param timeout [Numeric] deadline for each task/manager probe
|
|
23
|
+
def initialize(environment: ENV, timeout: 30)
|
|
24
|
+
@environment = environment
|
|
25
|
+
@timeout = timeout
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param root [String] application root
|
|
29
|
+
# @param child_command [Array<String>] argv ending in woods:watch
|
|
30
|
+
# @param manager_command [Array<String>, nil] verified Foreman start argv
|
|
31
|
+
# @param puma [Boolean] verify installed supported Puma in the application bundle
|
|
32
|
+
# @return [true]
|
|
33
|
+
def call(root:, child_command:, manager_command: nil, puma: false)
|
|
34
|
+
output = run(root, child_command[0...-1] + ['-T', 'woods:watch'])
|
|
35
|
+
unless output.match?(/\bwoods:watch\b/)
|
|
36
|
+
raise Conflict, 'Selected application command does not expose woods:watch; select its Rails task entrypoint'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
run(root, manager_command[0...-3] + ['--version']) if manager_command
|
|
40
|
+
run(root, ['bundle', 'exec', Gem.ruby, '-e', PUMA_CHECK]) if puma
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def run(root, command)
|
|
47
|
+
Open3.popen3(probe_environment(root), *command, chdir: root, pgroup: true,
|
|
48
|
+
unsetenv_others: true) do |input, output, error, child|
|
|
49
|
+
input.close
|
|
50
|
+
collect(output, error, child)
|
|
51
|
+
end
|
|
52
|
+
rescue SystemCallError => e
|
|
53
|
+
raise Conflict, "Startup preflight could not run: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def probe_environment(root)
|
|
57
|
+
environment = ChildEnvironment.build(@environment.to_h, root: root)
|
|
58
|
+
environment['BUNDLE_GEMFILE'] ||= File.join(root, 'Gemfile')
|
|
59
|
+
# A caller-selected lockfile without Bundler's activation markers is
|
|
60
|
+
# explicit configuration too; otherwise prefer the restored original.
|
|
61
|
+
unless @environment.key?('BUNDLER_ORIG_BUNDLE_LOCKFILE')
|
|
62
|
+
environment['BUNDLE_LOCKFILE'] ||= @environment['BUNDLE_LOCKFILE']
|
|
63
|
+
end
|
|
64
|
+
environment['BUNDLE_LOCKFILE'] ||= "#{environment['BUNDLE_GEMFILE']}.lock"
|
|
65
|
+
environment.merge('BUNDLE_FROZEN' => 'true')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def collect(output, error, child)
|
|
69
|
+
readers = [output, error].map { |io| Thread.new { bounded_read(io) } }
|
|
70
|
+
Timeout.timeout(@timeout) do
|
|
71
|
+
result, diagnostic = readers.map(&:value)
|
|
72
|
+
raise Conflict, "Startup preflight failed: #{diagnostic.strip}" unless child.value.success?
|
|
73
|
+
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
rescue Timeout::Error
|
|
77
|
+
raise Conflict, "Startup preflight exceeded #{@timeout}s; inspect the application task command"
|
|
78
|
+
ensure
|
|
79
|
+
terminate(child.pid)
|
|
80
|
+
readers.each(&:kill)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def bounded_read(io)
|
|
84
|
+
output = String.new
|
|
85
|
+
loop do
|
|
86
|
+
output << io.readpartial(16_384)
|
|
87
|
+
raise Conflict, 'Startup preflight exceeded 1 MiB of output' if output.bytesize > 1_048_576
|
|
88
|
+
end
|
|
89
|
+
rescue EOFError
|
|
90
|
+
output
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def terminate(pid)
|
|
94
|
+
Process.kill('KILL', -pid)
|
|
95
|
+
rescue Errno::ESRCH
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -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
|