woods 2.0.0.beta2 → 2.0.0.beta4
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 +339 -1
- data/CONTRIBUTING.md +188 -12
- data/README.md +93 -174
- data/SECURITY.md +9 -6
- data/docs/AGENT_GUIDE.md +109 -8
- data/docs/AGENT_SETUP.md +98 -7
- data/docs/BACKEND_MATRIX.md +25 -0
- data/docs/CLIENT_HOOKS.md +111 -0
- data/docs/CONFIGURATION_REFERENCE.md +267 -16
- data/docs/CONSOLE_MCP_SETUP.md +80 -7
- data/docs/DOCKER_SETUP.md +22 -3
- data/docs/EVALUATION.md +464 -1
- data/docs/EXTRACTOR_REFERENCE.md +45 -6
- data/docs/FAQ.md +11 -12
- data/docs/GETTING_STARTED.md +17 -5
- data/docs/INCREMENTAL_EXTRACTION.md +147 -7
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +7 -2
- data/docs/MCP_SERVERS.md +276 -5
- data/docs/MCP_TOOL_COOKBOOK.md +37 -22
- data/docs/MCP_WORKTREE_SETUP.md +43 -83
- data/docs/NOTION_INTEGRATION.md +13 -0
- data/docs/OBSIDIAN_INTEGRATION.md +57 -9
- data/docs/PUBLISHED_INDEX.md +72 -0
- data/docs/README.md +7 -0
- data/docs/RETRIEVAL_GUIDE.md +273 -12
- data/docs/RUNTIME_TRACING.md +71 -0
- data/docs/SOURCE_FRESHNESS.md +143 -0
- data/docs/TROUBLESHOOTING.md +129 -18
- data/docs/UNBLOCKED_INTEGRATION.md +25 -0
- data/docs/UPGRADING_TO_2.md +48 -22
- data/docs/WATCH_DAEMON.md +277 -67
- data/exe/woods-agent-config +6 -0
- data/exe/woods-extract +5 -0
- data/exe/woods-hook-context +6 -0
- data/exe/woods-mcp-start +14 -9
- data/lib/generators/woods/pgvector_generator.rb +8 -2
- data/lib/generators/woods/templates/woods.rb.tt +1 -3
- data/lib/tasks/woods.rake +47 -397
- data/lib/woods/agent_configuration/applier.rb +135 -0
- data/lib/woods/agent_configuration/cli.rb +101 -0
- data/lib/woods/agent_configuration/cli_options.rb +29 -0
- data/lib/woods/agent_configuration/document.rb +105 -0
- data/lib/woods/agent_configuration/error.rb +7 -0
- data/lib/woods/agent_configuration/launcher.rb +75 -0
- data/lib/woods/agent_configuration/layout.rb +72 -0
- data/lib/woods/agent_configuration/managed_section.rb +62 -0
- data/lib/woods/agent_configuration/plan.rb +98 -0
- data/lib/woods/agent_configuration/plan_diff.rb +38 -0
- data/lib/woods/agent_configuration/planned_files.rb +61 -0
- data/lib/woods/agent_configuration/planner.rb +63 -0
- data/lib/woods/agent_configuration/planner_validation.rb +77 -0
- data/lib/woods/agent_configuration/preflight.rb +100 -0
- data/lib/woods/agent_configuration/recovery.rb +49 -0
- data/lib/woods/ast/node.rb +2 -0
- data/lib/woods/ast/parser.rb +38 -5
- data/lib/woods/builder.rb +21 -5
- data/lib/woods/cache/cache_middleware.rb +28 -7
- data/lib/woods/cache/cache_store.rb +4 -5
- data/lib/woods/change_set.rb +5 -4
- data/lib/woods/console/credential_index.rb +20 -2
- data/lib/woods/console/credential_scanner.rb +18 -17
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/dispatch_pipeline.rb +7 -0
- data/lib/woods/console/embedded_executor.rb +32 -10
- data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
- data/lib/woods/console/rack_middleware.rb +22 -13
- data/lib/woods/console/server.rb +18 -16
- 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/coordination/pipeline_lock.rb +3 -2
- data/lib/woods/dependency_graph.rb +65 -13
- data/lib/woods/embedding/corpus.rb +94 -0
- data/lib/woods/embedding/indexer.rb +114 -60
- data/lib/woods/embedding/openai.rb +17 -6
- data/lib/woods/evaluation/ablation_executor.rb +6 -1
- data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
- data/lib/woods/export/typed_reader.rb +56 -0
- data/lib/woods/extractor.rb +277 -149
- data/lib/woods/extractors/action_cable_extractor.rb +3 -1
- data/lib/woods/extractors/behavioral_profile.rb +9 -7
- data/lib/woods/extractors/caching_extractor.rb +3 -1
- data/lib/woods/extractors/concern_extractor.rb +64 -6
- data/lib/woods/extractors/configuration_extractor.rb +7 -3
- data/lib/woods/extractors/controller_extractor.rb +13 -4
- data/lib/woods/extractors/database_view_extractor.rb +3 -1
- data/lib/woods/extractors/declared_parent.rb +55 -0
- data/lib/woods/extractors/decorator_extractor.rb +3 -1
- data/lib/woods/extractors/engine_extractor.rb +3 -1
- data/lib/woods/extractors/event_extractor.rb +4 -2
- data/lib/woods/extractors/factory_extractor.rb +3 -1
- data/lib/woods/extractors/graphql_extractor.rb +10 -13
- data/lib/woods/extractors/i18n_extractor.rb +3 -1
- data/lib/woods/extractors/job_extractor.rb +6 -19
- data/lib/woods/extractors/lib_extractor.rb +13 -9
- data/lib/woods/extractors/mailer_extractor.rb +26 -15
- data/lib/woods/extractors/manager_extractor.rb +3 -1
- data/lib/woods/extractors/method_parameters.rb +53 -0
- data/lib/woods/extractors/middleware_argument.rb +65 -0
- data/lib/woods/extractors/middleware_extractor.rb +9 -3
- data/lib/woods/extractors/migration_extractor.rb +3 -1
- data/lib/woods/extractors/model_extractor.rb +26 -34
- data/lib/woods/extractors/package_extractor.rb +24 -4
- data/lib/woods/extractors/phlex_extractor.rb +3 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -1
- data/lib/woods/extractors/poro_extractor.rb +13 -9
- data/lib/woods/extractors/pundit_extractor.rb +3 -1
- data/lib/woods/extractors/rails_source_extractor.rb +4 -2
- data/lib/woods/extractors/rake_task_extractor.rb +4 -2
- data/lib/woods/extractors/route_extractor.rb +3 -1
- data/lib/woods/extractors/route_helper_resolver.rb +10 -33
- data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
- data/lib/woods/extractors/serializer_extractor.rb +4 -2
- data/lib/woods/extractors/service_extractor.rb +3 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
- data/lib/woods/extractors/shared_utility_methods.rb +48 -19
- data/lib/woods/extractors/source_nesting.rb +1 -1
- data/lib/woods/extractors/state_machine_extractor.rb +3 -1
- data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
- data/lib/woods/extractors/validator_extractor.rb +3 -1
- data/lib/woods/extractors/view_component_extractor.rb +3 -1
- data/lib/woods/extractors/view_template_extractor.rb +3 -1
- data/lib/woods/gem_mapper.rb +2 -0
- data/lib/woods/git_history.rb +116 -0
- data/lib/woods/graph_analyzer.rb +35 -6
- data/lib/woods/hooks/context_cli.rb +54 -0
- data/lib/woods/hooks/context_event.rb +88 -0
- data/lib/woods/hooks/context_hint.rb +73 -0
- data/lib/woods/hooks/context_impact.rb +77 -0
- data/lib/woods/hooks/context_output.rb +47 -0
- data/lib/woods/hooks/context_state.rb +102 -0
- data/lib/woods/hooks/refresh.rb +79 -0
- data/lib/woods/hooks/rule_projection.rb +78 -0
- data/lib/woods/input_rules.rb +19 -0
- data/lib/woods/mcp/bearer_auth.rb +22 -13
- data/lib/woods/mcp/bootstrapper.rb +79 -4
- data/lib/woods/mcp/config_resolver.rb +2 -1
- data/lib/woods/mcp/index_reader.rb +334 -162
- data/lib/woods/mcp/initialization_guidance.rb +27 -0
- data/lib/woods/mcp/origin_guard.rb +17 -9
- data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +22 -9
- data/lib/woods/mcp/renderers/plain_renderer.rb +18 -8
- data/lib/woods/mcp/search_results.rb +74 -0
- data/lib/woods/mcp/server.rb +178 -63
- data/lib/woods/mcp/tool_contract.rb +3 -1
- data/lib/woods/mcp/tool_response_renderer.rb +41 -0
- data/lib/woods/mcp/traversal_evidence.rb +113 -0
- data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
- data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
- data/lib/woods/mcp/traversal_response.rb +22 -0
- data/lib/woods/notion/exporter.rb +56 -17
- data/lib/woods/obsidian/destination_plan.rb +98 -0
- data/lib/woods/obsidian/name_mapper.rb +19 -3
- data/lib/woods/obsidian/note_builder.rb +19 -10
- data/lib/woods/obsidian/vault_exporter.rb +88 -32
- data/lib/woods/operator/pipeline_guard.rb +18 -13
- data/lib/woods/path_dispatcher.rb +13 -6
- data/lib/woods/payload_store.rb +27 -26
- data/lib/woods/published_index/typed_unit_reader.rb +40 -3
- data/lib/woods/published_index.rb +2 -2
- data/lib/woods/railtie.rb +3 -3
- data/lib/woods/railtie_support.rb +12 -12
- data/lib/woods/rake_helpers.rb +382 -0
- data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
- data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
- data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
- data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
- data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
- data/lib/woods/resilience/index_validator.rb +112 -23
- data/lib/woods/retrieval/context_assembler.rb +50 -15
- data/lib/woods/retrieval/lexical_assembler.rb +84 -0
- data/lib/woods/retrieval/lexical_index.rb +120 -0
- data/lib/woods/retrieval/ranker.rb +4 -2
- data/lib/woods/retrieval/scope.rb +108 -0
- data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
- data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
- data/lib/woods/retrieval/search_executor.rb +86 -27
- data/lib/woods/retrieval/source_evidence.rb +200 -0
- data/lib/woods/retriever.rb +98 -22
- data/lib/woods/ruby_analyzer/trace_enricher.rb +77 -38
- data/lib/woods/session_tracer/file_store.rb +6 -1
- data/lib/woods/session_tracer/middleware.rb +10 -12
- data/lib/woods/session_tracer/redis_store.rb +22 -6
- data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
- data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
- data/lib/woods/session_tracer/unit_resolver.rb +63 -0
- data/lib/woods/source_inputs/consumer_errors.rb +31 -0
- data/lib/woods/source_inputs/handoff.rb +102 -0
- data/lib/woods/source_inputs/launcher.rb +157 -0
- data/lib/woods/source_inputs/manifest.rb +124 -0
- data/lib/woods/source_inputs/private_key.rb +55 -0
- data/lib/woods/source_inputs/scanner.rb +171 -0
- data/lib/woods/source_inputs/scopes.rb +71 -0
- data/lib/woods/source_inputs/session.rb +214 -0
- data/lib/woods/source_inputs/status.rb +84 -0
- data/lib/woods/source_inputs/verifier.rb +107 -0
- data/lib/woods/storage/metadata_store.rb +25 -25
- data/lib/woods/storage/pgvector.rb +35 -10
- data/lib/woods/storage/qdrant.rb +17 -7
- data/lib/woods/storage/vector_store.rb +18 -6
- data/lib/woods/tasks.rb +3 -2
- data/lib/woods/temporal/json_snapshot_store.rb +58 -9
- data/lib/woods/unblocked/exporter.rb +59 -70
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/boot_snapshot.rb +52 -0
- data/lib/woods/watch/daemon.rb +154 -32
- data/lib/woods/watch/listen_watcher.rb +4 -0
- data/lib/woods/watch/polling_watcher.rb +5 -1
- data/lib/woods/watch/status.rb +20 -15
- data/lib/woods/watch/tree_scan.rb +21 -13
- data/lib/woods/watch/watcher.rb +4 -1
- data/lib/woods.rb +50 -11
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.jq +15 -0
- data/plugin/hooks/adapters/normalize.rb +63 -0
- data/plugin/hooks/hooks.json +20 -0
- data/plugin/hooks/woods-context.sh +50 -0
- data/plugin/hooks/woods-input-rules.sh +159 -0
- data/plugin/hooks/woods-opencode.mjs +65 -0
- data/plugin/hooks/woods-post-edit.sh +2 -225
- data/plugin/hooks/woods-refresh.sh +260 -0
- data/plugin/hooks/woods-session-start.sh +47 -55
- data/plugin/skills/woods-agent-enable/SKILL.md +19 -0
- data/plugin/skills/woods-diagnose/SKILL.md +319 -1
- data/plugin/skills/woods-investigate/SKILL.md +145 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +90 -2
- data/plugin/skills/woods-setup/SKILL.md +110 -6
- metadata +87 -5
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'time'
|
|
4
|
+
require 'active_support/inflector'
|
|
4
5
|
|
|
5
6
|
module Woods
|
|
6
7
|
module SessionTracer
|
|
7
8
|
# Rack middleware that captures request metadata for session tracing.
|
|
8
9
|
#
|
|
9
10
|
# Wraps `@app.call(env)`, records after response. Extracts controller/action
|
|
10
|
-
# from
|
|
11
|
+
# from the dispatched controller instance and Rails path parameters. Session ID from
|
|
11
12
|
# `X-Trace-Session` header first, falls back to `request.session.id`.
|
|
12
13
|
#
|
|
13
14
|
# Fire-and-forget writes — `rescue StandardError` on recording, never breaks the request.
|
|
@@ -98,8 +99,7 @@ module Woods
|
|
|
98
99
|
action = path_params[:action]
|
|
99
100
|
return unless controller
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
controller_class = classify_controller(controller)
|
|
102
|
+
controller_class = controller_identity(env['action_controller.instance'], controller)
|
|
103
103
|
|
|
104
104
|
request_data = {
|
|
105
105
|
'session_id' => session_id,
|
|
@@ -144,15 +144,13 @@ module Woods
|
|
|
144
144
|
@exclude_paths.any? { |prefix| path.start_with?(prefix) }
|
|
145
145
|
end
|
|
146
146
|
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
.map { |segment| segment.split('_').map(&:capitalize).join }
|
|
155
|
-
"#{parts.join('::')}Controller"
|
|
147
|
+
# Runtime identity is authoritative. Rack adapters without a dispatched
|
|
148
|
+
# instance still use Rails' configured acronyms when classifying the route.
|
|
149
|
+
def controller_identity(instance, controller)
|
|
150
|
+
name = instance.class.name if instance
|
|
151
|
+
return name if name && !name.empty?
|
|
152
|
+
|
|
153
|
+
"#{ActiveSupport::Inflector.camelize(controller.to_s)}Controller"
|
|
156
154
|
end
|
|
157
155
|
|
|
158
156
|
# Extract response format from the Rack env.
|
|
@@ -56,6 +56,19 @@ module Woods
|
|
|
56
56
|
return 1
|
|
57
57
|
LUA
|
|
58
58
|
|
|
59
|
+
# Read and remove against either index format without converting a
|
|
60
|
+
# legacy SET. Each type check and operation is atomic with respect to
|
|
61
|
+
# a concurrent record's migration. Reader-only upgrades therefore do
|
|
62
|
+
# not prevent older SET writers from continuing to record.
|
|
63
|
+
INDEX_ACCESS_SCRIPT = <<~LUA
|
|
64
|
+
local legacy = redis.call('TYPE', KEYS[1]).ok == 'set'
|
|
65
|
+
if ARGV[1] == 'members' then
|
|
66
|
+
if legacy then return redis.call('SMEMBERS', KEYS[1]) end
|
|
67
|
+
return redis.call('ZRANGE', KEYS[1], 0, -1)
|
|
68
|
+
end
|
|
69
|
+
return redis.call(legacy and 'SREM' or 'ZREM', KEYS[1], ARGV[2])
|
|
70
|
+
LUA
|
|
71
|
+
|
|
59
72
|
# @param redis [Redis] A Redis client instance
|
|
60
73
|
# @param ttl [Integer, nil] Time-to-live in seconds for session keys (nil = no expiry)
|
|
61
74
|
def initialize(redis:, ttl: nil, max_sessions: DEFAULT_MAX_SESSIONS,
|
|
@@ -103,14 +116,13 @@ module Woods
|
|
|
103
116
|
# @param limit [Integer] Maximum number of sessions to return
|
|
104
117
|
# @return [Array<Hash>] Session summaries
|
|
105
118
|
def sessions(limit: 20)
|
|
106
|
-
all_ids =
|
|
119
|
+
all_ids = index_access('members')
|
|
107
120
|
|
|
108
121
|
# Filter to sessions that still have data (TTL may have expired)
|
|
109
122
|
active = all_ids.select { |id| @redis.exists?(session_key(id)) }
|
|
110
123
|
|
|
111
124
|
# Remove expired session IDs from the index
|
|
112
|
-
|
|
113
|
-
expired.each { |id| @redis.zrem(SESSIONS_KEY, id) } if expired.any?
|
|
125
|
+
(all_ids - active).each { |id| index_access('remove', id) }
|
|
114
126
|
|
|
115
127
|
# Redis sorted sets order by score, but a summary's last_request is
|
|
116
128
|
# the payload timestamp of the session's last record — the same
|
|
@@ -133,20 +145,24 @@ module Woods
|
|
|
133
145
|
# @return [void]
|
|
134
146
|
def clear(session_id)
|
|
135
147
|
@redis.del(session_key(session_id))
|
|
136
|
-
|
|
148
|
+
index_access('remove', session_id)
|
|
137
149
|
end
|
|
138
150
|
|
|
139
151
|
# Remove all session data.
|
|
140
152
|
#
|
|
141
153
|
# @return [void]
|
|
142
154
|
def clear_all
|
|
143
|
-
all_ids =
|
|
155
|
+
all_ids = index_access('members')
|
|
144
156
|
all_ids.each { |id| @redis.del(session_key(id)) }
|
|
145
157
|
@redis.del(SESSIONS_KEY)
|
|
146
158
|
end
|
|
147
159
|
|
|
148
160
|
private
|
|
149
161
|
|
|
162
|
+
def index_access(action, session_id = nil)
|
|
163
|
+
@redis.eval(INDEX_ACCESS_SCRIPT, keys: [SESSIONS_KEY], argv: [action, *session_id])
|
|
164
|
+
end
|
|
165
|
+
|
|
150
166
|
# @param session_id [String]
|
|
151
167
|
# @return [String] Redis key for this session
|
|
152
168
|
def session_key(session_id)
|
|
@@ -196,7 +212,7 @@ module Woods
|
|
|
196
212
|
|
|
197
213
|
victims.each do |id|
|
|
198
214
|
@redis.del(session_key(id))
|
|
199
|
-
|
|
215
|
+
index_access('remove', id)
|
|
200
216
|
end
|
|
201
217
|
end
|
|
202
218
|
|
|
@@ -4,6 +4,7 @@ require 'json'
|
|
|
4
4
|
require 'set'
|
|
5
5
|
require_relative '../token_utils'
|
|
6
6
|
require_relative 'session_flow_document'
|
|
7
|
+
require_relative 'unit_resolver'
|
|
7
8
|
|
|
8
9
|
module Woods
|
|
9
10
|
module SessionTracer
|
|
@@ -42,8 +43,14 @@ module Woods
|
|
|
42
43
|
# @param budget [Integer] Maximum token budget (default: 8000)
|
|
43
44
|
# @param depth [Integer] Expansion depth (0=metadata only, 1=direct deps, 2+=full flow)
|
|
44
45
|
# @return [SessionFlowDocument] The assembled document
|
|
45
|
-
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
46
46
|
def assemble(session_id, budget: 8000, depth: 1)
|
|
47
|
+
@reader.with_pinned_generation { assemble_pinned(session_id, budget: budget, depth: depth) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
53
|
+
def assemble_pinned(session_id, budget:, depth:)
|
|
47
54
|
requests = @store.read(session_id)
|
|
48
55
|
return empty_document(session_id) if requests.empty?
|
|
49
56
|
|
|
@@ -52,6 +59,7 @@ module Woods
|
|
|
52
59
|
side_effects = []
|
|
53
60
|
dependency_map = {}
|
|
54
61
|
seen_units = Set.new
|
|
62
|
+
resolver = UnitResolver.new(@reader)
|
|
55
63
|
|
|
56
64
|
requests.each_with_index do |req, idx|
|
|
57
65
|
step = build_step(req, idx)
|
|
@@ -63,18 +71,17 @@ module Woods
|
|
|
63
71
|
next unless controller_id
|
|
64
72
|
|
|
65
73
|
# Resolve controller unit
|
|
66
|
-
unit = @reader.find_unit(controller_id)
|
|
74
|
+
unit = @reader.find_unit(controller_id, type: 'controller')
|
|
67
75
|
if unit && !seen_units.include?(controller_id)
|
|
68
76
|
seen_units.add(controller_id)
|
|
69
77
|
context_pool[controller_id] = unit_summary(unit)
|
|
70
78
|
end
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
# Expand dependencies
|
|
79
|
+
# Expand dependencies only for a resolved controller.
|
|
74
80
|
next unless unit
|
|
75
81
|
|
|
82
|
+
step[:unit_refs] = [controller_id]
|
|
76
83
|
deps = resolve_dependencies(controller_id, seen_units, context_pool,
|
|
77
|
-
side_effects, step, dependency_map, depth)
|
|
84
|
+
side_effects, step, dependency_map, depth, resolver)
|
|
78
85
|
step[:unit_refs].concat(deps)
|
|
79
86
|
end
|
|
80
87
|
|
|
@@ -85,8 +92,6 @@ module Woods
|
|
|
85
92
|
budgeted_document(parts, budget)
|
|
86
93
|
end
|
|
87
94
|
|
|
88
|
-
private
|
|
89
|
-
|
|
90
95
|
# Build a timeline step from a request record.
|
|
91
96
|
#
|
|
92
97
|
# @param req [Hash] Request data from store
|
|
@@ -111,13 +116,13 @@ module Woods
|
|
|
111
116
|
# @return [Array<String>] Non-async dependency identifiers added
|
|
112
117
|
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
113
118
|
def resolve_dependencies(unit_id, seen_units, context_pool,
|
|
114
|
-
side_effects, step, dependency_map, depth)
|
|
119
|
+
side_effects, step, dependency_map, depth, resolver)
|
|
115
120
|
graph = @reader.dependency_graph
|
|
116
|
-
dep_ids = graph.dependencies_of(unit_id)
|
|
121
|
+
dep_ids = graph.dependencies_of(unit_id, type: :controller)
|
|
117
122
|
added = []
|
|
118
123
|
|
|
119
124
|
dep_ids.each do |dep_id|
|
|
120
|
-
dep_unit =
|
|
125
|
+
dep_unit = resolver.find(dep_id)
|
|
121
126
|
next unless dep_unit
|
|
122
127
|
|
|
123
128
|
dep_type = dep_unit['type']&.to_s
|
|
@@ -137,13 +142,13 @@ module Woods
|
|
|
137
142
|
added << dep_id
|
|
138
143
|
|
|
139
144
|
# Depth 2+: expand transitive dependencies
|
|
140
|
-
expand_transitive(dep_id, seen_units, context_pool, dependency_map, depth - 1) if depth >= 2
|
|
145
|
+
expand_transitive(dep_id, seen_units, context_pool, dependency_map, depth - 1, resolver) if depth >= 2
|
|
141
146
|
end
|
|
142
147
|
end
|
|
143
148
|
end
|
|
144
149
|
|
|
145
150
|
# Record dependency map for this unit
|
|
146
|
-
all_deps = dep_ids.select { |id|
|
|
151
|
+
all_deps = dep_ids.select { |id| resolver.find(id) }
|
|
147
152
|
dependency_map[unit_id] = all_deps if all_deps.any?
|
|
148
153
|
|
|
149
154
|
added
|
|
@@ -156,15 +161,16 @@ module Woods
|
|
|
156
161
|
# @param context_pool [Hash] Accumulator for unit data
|
|
157
162
|
# @param dependency_map [Hash] Accumulator for dependency edges
|
|
158
163
|
# @param remaining_depth [Integer] Remaining expansion depth
|
|
159
|
-
|
|
164
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
165
|
+
def expand_transitive(unit_id, seen_units, context_pool, dependency_map, remaining_depth, resolver)
|
|
160
166
|
return if remaining_depth <= 0
|
|
161
167
|
|
|
162
168
|
graph = @reader.dependency_graph
|
|
163
|
-
dep_ids = graph.dependencies_of(unit_id)
|
|
169
|
+
dep_ids = graph.dependencies_of(unit_id, type: resolver.find(unit_id).fetch('type').to_sym)
|
|
164
170
|
resolved_deps = []
|
|
165
171
|
|
|
166
172
|
dep_ids.each do |dep_id|
|
|
167
|
-
dep_unit =
|
|
173
|
+
dep_unit = resolver.find(dep_id)
|
|
168
174
|
next unless dep_unit
|
|
169
175
|
|
|
170
176
|
resolved_deps << dep_id
|
|
@@ -173,7 +179,7 @@ module Woods
|
|
|
173
179
|
seen_units.add(dep_id)
|
|
174
180
|
context_pool[dep_id] = unit_summary(dep_unit)
|
|
175
181
|
|
|
176
|
-
expand_transitive(dep_id, seen_units, context_pool, dependency_map, remaining_depth - 1)
|
|
182
|
+
expand_transitive(dep_id, seen_units, context_pool, dependency_map, remaining_depth - 1, resolver)
|
|
177
183
|
end
|
|
178
184
|
|
|
179
185
|
dependency_map[unit_id] = resolved_deps if resolved_deps.any?
|
|
@@ -157,11 +157,13 @@ module Woods
|
|
|
157
157
|
end
|
|
158
158
|
|
|
159
159
|
def inserted?(entry_class, result, key, payload)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
# MySQL's FOUND_ROWS can count a duplicate no-op as an affected row.
|
|
161
|
+
# The per-attempt CAS version makes equal values distinct payloads, so
|
|
162
|
+
# only our exact stored bytes establish ownership without RETURNING.
|
|
163
|
+
return entry_class.read(key) == payload unless entry_class.connection.supports_insert_returning?
|
|
163
164
|
|
|
164
|
-
|
|
165
|
+
affected_rows = result.affected_rows if result.respond_to?(:affected_rows)
|
|
166
|
+
affected_rows.nil? ? result.rows.any? : affected_rows.positive?
|
|
165
167
|
end
|
|
166
168
|
|
|
167
169
|
def routed_read(key, failsafe)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module SessionTracer
|
|
5
|
+
# A bare dependency name cannot choose between published extraction types.
|
|
6
|
+
class AmbiguousUnitError < StandardError
|
|
7
|
+
attr_reader :identifier, :types
|
|
8
|
+
|
|
9
|
+
def initialize(identifier, types)
|
|
10
|
+
@identifier = identifier
|
|
11
|
+
@types = types.sort.freeze
|
|
12
|
+
super("Ambiguous session unit #{identifier.inspect}: published types #{@types.join(', ')}. " \
|
|
13
|
+
'No session context was returned. Use depth: 0 for the timeline, or inspect each type with lookup.')
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Per-assembly resolver. The caller pins its complete lifetime to one generation.
|
|
18
|
+
# Summary entries may omit type in legacy indexes; the containing bucket and
|
|
19
|
+
# validated unit body establish the concrete type without reading unrelated bodies.
|
|
20
|
+
class UnitResolver
|
|
21
|
+
def initialize(reader)
|
|
22
|
+
@reader = reader
|
|
23
|
+
@resolved = {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def find(identifier)
|
|
27
|
+
return @resolved[identifier] if @resolved.key?(identifier)
|
|
28
|
+
|
|
29
|
+
units = candidate_types.fetch(identifier, []).filter_map do |type|
|
|
30
|
+
@reader.find_unit(identifier, type: type)
|
|
31
|
+
end
|
|
32
|
+
raise AmbiguousUnitError.new(identifier, units.map { |unit| unit.fetch('type') }) if units.size > 1
|
|
33
|
+
|
|
34
|
+
@resolved[identifier] = units.first
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def entries_for(dir)
|
|
40
|
+
path = @reader.payload_dir.join(dir, '_index.json')
|
|
41
|
+
raise IOError, "symlink unit index: #{dir}" if path.symlink?
|
|
42
|
+
|
|
43
|
+
entries = @reader.list_units(type: MCP::IndexReader::DIR_TO_TYPE.fetch(dir))
|
|
44
|
+
expected = @reader.manifest.dig('counts', dir)
|
|
45
|
+
if expected.is_a?(Integer) && expected != entries.size
|
|
46
|
+
raise IOError, "unit count mismatch in #{dir}: expected #{expected}, found #{entries.size}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
entries
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def candidate_types
|
|
53
|
+
@candidate_types ||= MCP::IndexReader::UNIT_TYPES_BY_DIR.each_with_object({}) do |(dir, types), candidates|
|
|
54
|
+
entries_for(dir).each do |entry|
|
|
55
|
+
identifier = entry.fetch('identifier')
|
|
56
|
+
candidates[identifier] ||= []
|
|
57
|
+
candidates[identifier] |= types
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module SourceInputs
|
|
5
|
+
# Error handlers can return the same nil/empty result as a successful
|
|
6
|
+
# negative match. Keep explicit evidence on the consumer instance instead
|
|
7
|
+
# of guessing from results or intercepting the application's logger.
|
|
8
|
+
module ConsumerErrors
|
|
9
|
+
FLAG = :@woods_source_consumer_failed
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def record(consumer)
|
|
14
|
+
consumer.instance_variable_set(FLAG, true)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reset(consumer)
|
|
18
|
+
consumer.instance_variable_set(FLAG, false)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def failed?(consumer)
|
|
22
|
+
consumer && consumer.instance_variable_get(FLAG) == true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def log(consumer, ...)
|
|
26
|
+
record(consumer)
|
|
27
|
+
Rails.logger.error(...)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'woods/source_inputs/manifest'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module SourceInputs
|
|
8
|
+
# Private one-process launch handoff. Its environment token names neither an
|
|
9
|
+
# existing index manifest nor a user-supplied proof of runtime freshness.
|
|
10
|
+
module Handoff
|
|
11
|
+
ENV_KEY = 'WOODS_SOURCE_CAPTURE'
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
16
|
+
# Every independent handoff binding must match before a token is consumed.
|
|
17
|
+
def read(root:, output_dir:, operation:, rules:, key_id:)
|
|
18
|
+
token = ENV.fetch(ENV_KEY, nil)
|
|
19
|
+
return nil if token.nil? || token.empty?
|
|
20
|
+
|
|
21
|
+
descriptor = JSON.parse(token)
|
|
22
|
+
return nil unless descriptor.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
data = private_data(descriptor.fetch('path'))
|
|
25
|
+
return nil unless data.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
expected = { 'version' => 1, 'nonce' => descriptor.fetch('nonce'),
|
|
28
|
+
'root' => File.expand_path(root.to_s), 'output' => File.expand_path(output_dir.to_s),
|
|
29
|
+
'operation' => operation.to_s, 'rules' => rules, 'launcher_pid' => Process.ppid }
|
|
30
|
+
return nil unless expected.all? { |key, value| data[key] == value }
|
|
31
|
+
return nil unless valid_nonce?(data['nonce'])
|
|
32
|
+
|
|
33
|
+
snapshot = data.fetch('snapshot')
|
|
34
|
+
return nil unless valid_snapshot?(snapshot, expected, key_id)
|
|
35
|
+
|
|
36
|
+
ENV.delete(ENV_KEY)
|
|
37
|
+
snapshot
|
|
38
|
+
rescue JSON::ParserError, KeyError, TypeError, SystemCallError, IOError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
43
|
+
|
|
44
|
+
def private_data(path)
|
|
45
|
+
flags = File::RDONLY | File::NONBLOCK
|
|
46
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
47
|
+
File.open(path, flags) do |file|
|
|
48
|
+
stat = file.stat
|
|
49
|
+
return nil unless stat.file? && stat.uid == Process.uid && stat.mode.nobits?(0o077)
|
|
50
|
+
return nil if stat.size > Manifest::MAX_BYTES
|
|
51
|
+
|
|
52
|
+
JSON.parse(file.read(Manifest::MAX_BYTES + 1))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def valid_nonce?(value)
|
|
57
|
+
value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def valid_snapshot?(snapshot, expected, key_id)
|
|
61
|
+
return false unless matching_snapshot?(snapshot, expected, key_id)
|
|
62
|
+
|
|
63
|
+
files = snapshot['files']
|
|
64
|
+
paths = snapshot['scope_paths']
|
|
65
|
+
return false unless valid_tables?(files, paths)
|
|
66
|
+
|
|
67
|
+
expanded = paths.transform_values { |list| list.to_h { |path| [path, files.fetch(path)] } }
|
|
68
|
+
Manifest.build(snapshot: snapshot, scopes: expanded, boot_verified: true, generation: 1)
|
|
69
|
+
true
|
|
70
|
+
rescue Manifest::Invalid, KeyError, TypeError
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def matching_snapshot?(snapshot, expected, key_id)
|
|
75
|
+
snapshot.is_a?(Hash) && snapshot['root'] == expected['root'] &&
|
|
76
|
+
snapshot['rules'] == expected['rules'] && snapshot['key_id'] == key_id
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
80
|
+
def valid_tables?(files, paths)
|
|
81
|
+
files.is_a?(Hash) && paths.is_a?(Hash) &&
|
|
82
|
+
files.all? { |path, digest| path.is_a?(String) && valid_nonce?(digest) } &&
|
|
83
|
+
paths.values.all? { |list| list.is_a?(Array) && list.all? { |path| files.key?(path) } }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
87
|
+
|
|
88
|
+
def extra_roots
|
|
89
|
+
token = ENV.fetch(ENV_KEY, nil)
|
|
90
|
+
return [] if token.nil? || token.empty?
|
|
91
|
+
|
|
92
|
+
descriptor = JSON.parse(token)
|
|
93
|
+
return [] unless descriptor.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
roots = descriptor.fetch('extra_roots', [])
|
|
96
|
+
roots.is_a?(Array) ? roots : []
|
|
97
|
+
rescue JSON::ParserError, TypeError
|
|
98
|
+
[]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
require 'base64'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
require 'woods/source_inputs/scanner'
|
|
8
|
+
require 'woods/source_inputs/handoff'
|
|
9
|
+
require 'woods/hooks/refresh'
|
|
10
|
+
|
|
11
|
+
module Woods
|
|
12
|
+
module SourceInputs
|
|
13
|
+
# Captures before a fresh child evaluates Bundler, the Rakefile and Rails.
|
|
14
|
+
# The parent waits so all exit paths can remove the private handoff.
|
|
15
|
+
class Launcher # rubocop:disable Metrics/ClassLength -- parser and child lifetime form one CLI contract
|
|
16
|
+
OPERATIONS = %w[full incremental refresh].freeze
|
|
17
|
+
|
|
18
|
+
def self.run(argv, **options)
|
|
19
|
+
new(argv, **options).run
|
|
20
|
+
rescue OptionParser::ParseError, ArgumentError, PrivateKey::Unavailable, SystemCallError => e
|
|
21
|
+
warn "woods-extract: #{e.message}"
|
|
22
|
+
1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(argv, command: %w[bundle exec rake])
|
|
26
|
+
@arguments = argv.dup
|
|
27
|
+
@command = command
|
|
28
|
+
@original_directory = Dir.pwd
|
|
29
|
+
@root = @original_directory
|
|
30
|
+
@output = ENV.fetch('WOODS_OUTPUT', 'tmp/woods')
|
|
31
|
+
@extra_roots = []
|
|
32
|
+
parse!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run # rubocop:disable Metrics/MethodLength -- handoff lifetime encloses the fresh child
|
|
36
|
+
return 0 if @help
|
|
37
|
+
|
|
38
|
+
operation, task = task_for_arguments
|
|
39
|
+
key = PrivateKey.new(output_dir: @output, create: true)
|
|
40
|
+
rules = Scopes.new(extra_roots: @extra_roots)
|
|
41
|
+
snapshot = Scanner.new(root: @root, output_dir: @output, key: key, scopes: rules).call
|
|
42
|
+
nonce = SecureRandom.hex(32)
|
|
43
|
+
Tempfile.create(['woods-source-capture-', '.json']) do |file|
|
|
44
|
+
file.binmode
|
|
45
|
+
file.chmod(0o600)
|
|
46
|
+
file.write(JSON.generate(version: 1, nonce: nonce, root: @root, output: @output,
|
|
47
|
+
operation: operation, rules: rules.fingerprint,
|
|
48
|
+
launcher_pid: Process.pid, snapshot: snapshot))
|
|
49
|
+
file.flush
|
|
50
|
+
file.fsync
|
|
51
|
+
descriptor = JSON.generate(path: file.path, nonce: nonce, extra_roots: @extra_roots)
|
|
52
|
+
environment = { Handoff::ENV_KEY => descriptor, 'WOODS_OUTPUT' => @output }
|
|
53
|
+
if ENV['BUNDLE_GEMFILE']
|
|
54
|
+
environment['BUNDLE_GEMFILE'] = File.expand_path(ENV.fetch('BUNDLE_GEMFILE'), @original_directory)
|
|
55
|
+
end
|
|
56
|
+
run_child(environment, task)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def parse!
|
|
63
|
+
parser.permute!(@arguments)
|
|
64
|
+
validate_arguments! unless @help
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parser
|
|
68
|
+
OptionParser.new do |options|
|
|
69
|
+
options.banner = 'Usage: woods-extract [options] full | incremental PATH... | refresh TYPE...'
|
|
70
|
+
options.on('--root PATH', 'Application root (default: current directory)') { |path| @root = path }
|
|
71
|
+
options.on('--output PATH', 'Index output (default: WOODS_OUTPUT or tmp/woods)') { |path| @output = path }
|
|
72
|
+
options.on('--source-root PATH', 'Additional application-relative runtime source root') do |path|
|
|
73
|
+
@extra_roots << path
|
|
74
|
+
end
|
|
75
|
+
options.on('-h', '--help', 'Show usage') do
|
|
76
|
+
puts options
|
|
77
|
+
@help = true
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_arguments!
|
|
83
|
+
@root = File.expand_path(@root)
|
|
84
|
+
raise ArgumentError, 'application root does not exist' unless File.directory?(@root)
|
|
85
|
+
|
|
86
|
+
@output = File.expand_path(@output, @root)
|
|
87
|
+
@operation = @arguments.shift
|
|
88
|
+
raise ArgumentError, 'choose full, incremental, or refresh' unless OPERATIONS.include?(@operation)
|
|
89
|
+
if @operation == 'full' && !@arguments.empty?
|
|
90
|
+
raise ArgumentError,
|
|
91
|
+
'full does not accept paths or extractor names'
|
|
92
|
+
end
|
|
93
|
+
if @operation != 'full' && @arguments.empty?
|
|
94
|
+
raise ArgumentError,
|
|
95
|
+
"#{@operation} requires paths or extractor names"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@extra_roots = Scopes.new(extra_roots: @extra_roots).extra_roots
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def task_for_arguments
|
|
102
|
+
return ['full', 'woods:extract'] if @operation == 'full'
|
|
103
|
+
return refresh_task if @operation == 'refresh'
|
|
104
|
+
|
|
105
|
+
incremental_task
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def incremental_task
|
|
109
|
+
paths = @arguments.map { |path| relative_path(path) }.uniq
|
|
110
|
+
action = InputRules.new
|
|
111
|
+
operation = paths.any? { |path| action.action(path) == :full } ? 'full' : 'incremental'
|
|
112
|
+
if paths.size > Hooks::Refresh::MAX_EVENTS
|
|
113
|
+
raise ArgumentError, 'too many incremental paths; split the input or run full'
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
batch = Base64.strict_encode64(JSON.generate(version: 1, output: @output,
|
|
117
|
+
events: paths.map { |path| { path: path, operation: 'update' } }))
|
|
118
|
+
raise ArgumentError, 'incremental batch too large; split the input or run full' if batch.bytesize > 120_000
|
|
119
|
+
|
|
120
|
+
[operation, "woods:hook_refresh[#{batch}]"]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def refresh_task
|
|
124
|
+
names = @arguments.uniq
|
|
125
|
+
unless names.all? { |name| name.match?(/\A[a-z_]+\z/) && Extractor::EXTRACTORS.key?(name.to_sym) }
|
|
126
|
+
raise ArgumentError, 'refresh requires known extractor names'
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
['refresh', "woods:refresh[#{names.join(',')}]"]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def relative_path(path)
|
|
133
|
+
absolute = File.expand_path(path, @root)
|
|
134
|
+
unless absolute.start_with?("#{@root}/") && !path.include?("\0")
|
|
135
|
+
raise ArgumentError, 'incremental paths must be inside the application root'
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
absolute.delete_prefix("#{@root}/")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def run_child(environment, task)
|
|
142
|
+
pid = Process.spawn(environment, *@command, task, chdir: @root, pgroup: true)
|
|
143
|
+
previous = %w[INT TERM].to_h do |signal|
|
|
144
|
+
[signal, Signal.trap(signal) do
|
|
145
|
+
Process.kill(signal, -pid)
|
|
146
|
+
rescue StandardError
|
|
147
|
+
nil
|
|
148
|
+
end]
|
|
149
|
+
end
|
|
150
|
+
_finished, status = Process.wait2(pid)
|
|
151
|
+
status.exitstatus || (128 + status.termsig)
|
|
152
|
+
ensure
|
|
153
|
+
previous&.each { |signal, handler| Signal.trap(signal, handler) }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|