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,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Watch
|
|
7
|
+
# Bounded framing plus the guardian-issued child identity used for cleanup.
|
|
8
|
+
class EventStream
|
|
9
|
+
MAX_BYTES = 4096
|
|
10
|
+
|
|
11
|
+
def initialize(reader:, guardian:, launcher:, attempt:)
|
|
12
|
+
@reader = reader
|
|
13
|
+
@guardian = guardian
|
|
14
|
+
@identity = { 'launcher' => launcher, 'attempt' => attempt, 'pid' => guardian, 'event' => 'spawned' }
|
|
15
|
+
@buffer = +''
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :child_pid
|
|
19
|
+
|
|
20
|
+
def read
|
|
21
|
+
records = []
|
|
22
|
+
return records if @reader.closed?
|
|
23
|
+
|
|
24
|
+
16.times do
|
|
25
|
+
part = @reader.read_nonblock(MAX_BYTES, exception: false)
|
|
26
|
+
break if part == :wait_readable
|
|
27
|
+
return finish(records) if part.nil?
|
|
28
|
+
|
|
29
|
+
@buffer << part
|
|
30
|
+
drain_lines(records)
|
|
31
|
+
end
|
|
32
|
+
records
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def eof?
|
|
36
|
+
@eof == true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def finish(records)
|
|
42
|
+
@eof = true
|
|
43
|
+
raise ArgumentError, 'truncated Woods lifecycle record' unless @buffer.empty?
|
|
44
|
+
|
|
45
|
+
records
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def drain_lines(records)
|
|
49
|
+
while @buffer.include?("\n")
|
|
50
|
+
record = @buffer.slice!(0..@buffer.index("\n"))
|
|
51
|
+
raise ArgumentError, 'oversized Woods lifecycle record' if record.bytesize > MAX_BYTES
|
|
52
|
+
|
|
53
|
+
remember_child(record)
|
|
54
|
+
records << record
|
|
55
|
+
end
|
|
56
|
+
raise ArgumentError, 'oversized Woods lifecycle record' if @buffer.bytesize > MAX_BYTES
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def remember_child(line)
|
|
60
|
+
record = JSON.parse(line)
|
|
61
|
+
return unless record.is_a?(Hash) && @identity.all? { |key, value| record[key] == value }
|
|
62
|
+
|
|
63
|
+
child = record['child_pid']
|
|
64
|
+
@child_pid = child if child.is_a?(Integer) && child.positive?
|
|
65
|
+
rescue JSON::ParserError
|
|
66
|
+
nil # The lifecycle validator reports malformed records.
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'io/wait'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Watch
|
|
8
|
+
# Minimal pre-Bundler owner. EOF on its private parent pipe always stops
|
|
9
|
+
# the owned application group, even while an initializer is blocked.
|
|
10
|
+
class Guardian
|
|
11
|
+
# @param parent [IO] private parent-liveness reader
|
|
12
|
+
# @param events [IO] lifecycle writer
|
|
13
|
+
# @param config [Hash] argument vector and child environment
|
|
14
|
+
def initialize(parent:, events:, config:)
|
|
15
|
+
@parent = parent
|
|
16
|
+
@events = events
|
|
17
|
+
@config = config
|
|
18
|
+
@events.sync = true
|
|
19
|
+
@stopping = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Integer] application exit status, or launch failure
|
|
23
|
+
def run
|
|
24
|
+
%w[INT TERM].each { |signal| Signal.trap(signal) { @stopping = true } }
|
|
25
|
+
report('hello')
|
|
26
|
+
spawn_child
|
|
27
|
+
monitor
|
|
28
|
+
report('exit', code: @status&.exitstatus, signal: @status&.termsig)
|
|
29
|
+
@status&.exitstatus || 1
|
|
30
|
+
rescue SystemCallError => e
|
|
31
|
+
report('spawn_error', reason: e.class.name.split('::').last)
|
|
32
|
+
127
|
|
33
|
+
ensure
|
|
34
|
+
terminate_group
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def spawn_child
|
|
40
|
+
gate, release = IO.pipe
|
|
41
|
+
@child = fork do
|
|
42
|
+
release.close
|
|
43
|
+
@parent.close
|
|
44
|
+
Process.setpgrp
|
|
45
|
+
exit! 1 unless gate.read(1) == 'S'
|
|
46
|
+
|
|
47
|
+
gate.close
|
|
48
|
+
exec_child
|
|
49
|
+
end
|
|
50
|
+
gate.close
|
|
51
|
+
report('spawned', child_pid: @child)
|
|
52
|
+
release.write('S') if acknowledged?
|
|
53
|
+
ensure
|
|
54
|
+
release&.close unless release&.closed?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Do not execute application code until its owner knows the child group.
|
|
58
|
+
# Guardian death before acknowledgment closes the exec gate with EOF.
|
|
59
|
+
def acknowledged?
|
|
60
|
+
until @stopping || Process.ppid != @config.fetch('owner_pid')
|
|
61
|
+
next unless @parent.wait_readable(0.05)
|
|
62
|
+
|
|
63
|
+
return @parent.read(1) == 'S'
|
|
64
|
+
end
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def exec_child
|
|
69
|
+
options = { chdir: @config.fetch('root'), in: File::NULL, close_others: true, unsetenv_others: true }
|
|
70
|
+
options[3] = @events if @config['events']
|
|
71
|
+
command = @config.fetch('command')
|
|
72
|
+
Process.exec(@config.fetch('env'), [command.first, command.first], *command.drop(1), **options)
|
|
73
|
+
rescue SystemCallError => e
|
|
74
|
+
warn "[woods-watch] child executable could not start (#{e.class})"
|
|
75
|
+
exit! 127
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def monitor
|
|
79
|
+
until @stopping
|
|
80
|
+
break if Process.ppid != @config.fetch('owner_pid')
|
|
81
|
+
|
|
82
|
+
result = Process.waitpid2(@child, Process::WNOHANG)
|
|
83
|
+
if result
|
|
84
|
+
@status = result.last
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
@stopping = true if @parent.wait_readable(0.05) && @parent.read_nonblock(1, exception: false).nil?
|
|
88
|
+
end
|
|
89
|
+
terminate_group
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def terminate_group
|
|
93
|
+
return unless @child && !@terminated
|
|
94
|
+
|
|
95
|
+
signal_group('TERM')
|
|
96
|
+
await_child
|
|
97
|
+
signal_group('KILL')
|
|
98
|
+
@status ||= Process.waitpid2(@child).last
|
|
99
|
+
@terminated = true
|
|
100
|
+
rescue Errno::ECHILD
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def await_child
|
|
105
|
+
deadline = monotonic + @config.fetch('shutdown_timeout')
|
|
106
|
+
until @status || monotonic >= deadline
|
|
107
|
+
@status = Process.waitpid2(@child, Process::WNOHANG)&.last
|
|
108
|
+
sleep 0.02 unless @status
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def signal_group(signal)
|
|
113
|
+
Process.kill(signal, -@child)
|
|
114
|
+
rescue Errno::ESRCH
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def monotonic
|
|
119
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def report(event, **fields)
|
|
123
|
+
record = { version: 1, event: event, pid: Process.pid,
|
|
124
|
+
launcher: @config['launcher'], attempt: @config['attempt'] }.merge(fields)
|
|
125
|
+
@events.write(JSON.generate(record) << "\n")
|
|
126
|
+
rescue Errno::EPIPE
|
|
127
|
+
@stopping = true
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if $PROGRAM_NAME == __FILE__
|
|
134
|
+
config_io = IO.for_fd(5)
|
|
135
|
+
config = JSON.parse(config_io.read)
|
|
136
|
+
config_io.close
|
|
137
|
+
parent = IO.for_fd(4)
|
|
138
|
+
events = IO.for_fd(3, 'w')
|
|
139
|
+
parent.close_on_exec = true
|
|
140
|
+
events.close_on_exec = true
|
|
141
|
+
exit Woods::Watch::Guardian.new(parent: parent, events: events, config: config).run
|
|
142
|
+
end
|
|
@@ -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
|