archsight 0.1.2 → 0.1.3
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/README.md +26 -5
- data/lib/archsight/analysis/executor.rb +112 -0
- data/lib/archsight/analysis/result.rb +174 -0
- data/lib/archsight/analysis/sandbox.rb +319 -0
- data/lib/archsight/analysis.rb +11 -0
- data/lib/archsight/annotations/architecture_annotations.rb +2 -2
- data/lib/archsight/cli.rb +163 -0
- data/lib/archsight/database.rb +6 -2
- data/lib/archsight/helpers/analysis_renderer.rb +83 -0
- data/lib/archsight/helpers/formatting.rb +95 -0
- data/lib/archsight/helpers.rb +20 -4
- data/lib/archsight/import/concurrent_progress.rb +341 -0
- data/lib/archsight/import/executor.rb +466 -0
- data/lib/archsight/import/git_analytics.rb +626 -0
- data/lib/archsight/import/handler.rb +263 -0
- data/lib/archsight/import/handlers/github.rb +161 -0
- data/lib/archsight/import/handlers/gitlab.rb +202 -0
- data/lib/archsight/import/handlers/jira_base.rb +189 -0
- data/lib/archsight/import/handlers/jira_discover.rb +161 -0
- data/lib/archsight/import/handlers/jira_metrics.rb +179 -0
- data/lib/archsight/import/handlers/openapi_schema_parser.rb +279 -0
- data/lib/archsight/import/handlers/repository.rb +439 -0
- data/lib/archsight/import/handlers/rest_api.rb +293 -0
- data/lib/archsight/import/handlers/rest_api_index.rb +183 -0
- data/lib/archsight/import/progress.rb +91 -0
- data/lib/archsight/import/registry.rb +54 -0
- data/lib/archsight/import/shared_file_writer.rb +67 -0
- data/lib/archsight/import/team_matcher.rb +195 -0
- data/lib/archsight/import.rb +14 -0
- data/lib/archsight/resources/analysis.rb +91 -0
- data/lib/archsight/resources/application_component.rb +2 -2
- data/lib/archsight/resources/application_service.rb +12 -12
- data/lib/archsight/resources/business_product.rb +12 -12
- data/lib/archsight/resources/data_object.rb +1 -1
- data/lib/archsight/resources/import.rb +79 -0
- data/lib/archsight/resources/technology_artifact.rb +23 -2
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/api/docs.rb +17 -0
- data/lib/archsight/web/api/json_helpers.rb +164 -0
- data/lib/archsight/web/api/openapi/spec.yaml +500 -0
- data/lib/archsight/web/api/routes.rb +101 -0
- data/lib/archsight/web/application.rb +66 -43
- data/lib/archsight/web/doc/import.md +458 -0
- data/lib/archsight/web/doc/index.md.erb +1 -0
- data/lib/archsight/web/public/css/artifact.css +10 -0
- data/lib/archsight/web/public/css/graph.css +14 -0
- data/lib/archsight/web/public/css/instance.css +489 -0
- data/lib/archsight/web/views/api_docs.erb +19 -0
- data/lib/archsight/web/views/partials/artifact/_project_estimate.haml +14 -8
- data/lib/archsight/web/views/partials/instance/_analysis_detail.haml +74 -0
- data/lib/archsight/web/views/partials/instance/_analysis_result.haml +64 -0
- data/lib/archsight/web/views/partials/instance/_detail.haml +7 -3
- data/lib/archsight/web/views/partials/instance/_import_detail.haml +87 -0
- data/lib/archsight/web/views/partials/instance/_relations.haml +4 -4
- data/lib/archsight/web/views/partials/layout/_content.haml +4 -0
- data/lib/archsight/web/views/partials/layout/_navigation.haml +6 -5
- metadata +78 -1
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "archsight/import"
|
|
6
|
+
require_relative "registry"
|
|
7
|
+
require_relative "handler"
|
|
8
|
+
require_relative "progress"
|
|
9
|
+
require_relative "concurrent_progress"
|
|
10
|
+
require_relative "shared_file_writer"
|
|
11
|
+
|
|
12
|
+
# Executes imports in dependency order with concurrent processing
|
|
13
|
+
#
|
|
14
|
+
# The executor:
|
|
15
|
+
# 1. Loads all Import resources from the database
|
|
16
|
+
# 2. Finds pending imports whose dependencies are satisfied
|
|
17
|
+
# 3. Executes ready imports concurrently (up to max_concurrent)
|
|
18
|
+
# 4. Reloads database once after batch completes to discover new imports
|
|
19
|
+
# 5. Repeats until no pending imports remain
|
|
20
|
+
# 6. Stops immediately on first error
|
|
21
|
+
class Archsight::Import::Executor
|
|
22
|
+
MAX_CONCURRENT = 20
|
|
23
|
+
|
|
24
|
+
# Duration parsing patterns for cache time
|
|
25
|
+
DURATION_PATTERNS = {
|
|
26
|
+
/^(\d+)s$/ => 1,
|
|
27
|
+
/^(\d+)m$/ => 60,
|
|
28
|
+
/^(\d+)h$/ => 3600,
|
|
29
|
+
/^(\d+)d$/ => 86_400
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :database, :resources_dir, :verbose, :filter, :force
|
|
33
|
+
|
|
34
|
+
# @param database [Archsight::Database] Database instance
|
|
35
|
+
# @param resources_dir [String] Root resources directory
|
|
36
|
+
# @param verbose [Boolean] Whether to print verbose debug output
|
|
37
|
+
# @param max_concurrent [Integer] Maximum concurrent imports (default: 20)
|
|
38
|
+
# @param output [IO] Output stream for progress (default: $stdout)
|
|
39
|
+
# @param filter [String, nil] Regex pattern to match import names
|
|
40
|
+
# @param force [Boolean] Whether to bypass cache and re-run all imports
|
|
41
|
+
def initialize(database:, resources_dir:, verbose: false, max_concurrent: MAX_CONCURRENT, output: $stdout, filter: nil, force: false)
|
|
42
|
+
@database = database
|
|
43
|
+
@resources_dir = resources_dir
|
|
44
|
+
@verbose = verbose
|
|
45
|
+
@max_concurrent = max_concurrent
|
|
46
|
+
@output = output
|
|
47
|
+
@filter = filter
|
|
48
|
+
@filter_regex = Regexp.new(filter, Regexp::IGNORECASE) if filter
|
|
49
|
+
@force = force
|
|
50
|
+
@executed_this_run = Set.new
|
|
51
|
+
@iteration = 0
|
|
52
|
+
@mutex = Mutex.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Run all pending imports
|
|
56
|
+
# @raise [DeadlockError] if pending imports have unsatisfied dependencies
|
|
57
|
+
# @raise [ImportError] if an import fails
|
|
58
|
+
def run!
|
|
59
|
+
@total_executed = 0
|
|
60
|
+
@total_cached = 0
|
|
61
|
+
@failed_imports = {}
|
|
62
|
+
@first_error = nil
|
|
63
|
+
@interrupted = false
|
|
64
|
+
@concurrent_progress = Archsight::Import::ConcurrentProgress.new(max_slots: @max_concurrent, output: @output)
|
|
65
|
+
@shared_writer = Archsight::Import::SharedFileWriter.new
|
|
66
|
+
|
|
67
|
+
# Set up graceful shutdown on Ctrl-C
|
|
68
|
+
setup_signal_handlers
|
|
69
|
+
|
|
70
|
+
# Calculate total imports for overall progress
|
|
71
|
+
reload_database_quietly!
|
|
72
|
+
total = count_all_enabled_imports
|
|
73
|
+
@concurrent_progress.total = total if total.positive?
|
|
74
|
+
|
|
75
|
+
# Track if we need to reload (skip first iteration since we just reloaded)
|
|
76
|
+
need_reload = false
|
|
77
|
+
|
|
78
|
+
loop do
|
|
79
|
+
break if @interrupted
|
|
80
|
+
|
|
81
|
+
@iteration += 1
|
|
82
|
+
log "=== Iteration #{@iteration} ==="
|
|
83
|
+
|
|
84
|
+
# Only reload if previous batch executed imports (might have generated new Import resources)
|
|
85
|
+
reload_and_update_total! if need_reload
|
|
86
|
+
|
|
87
|
+
# Get all pending imports
|
|
88
|
+
pending = pending_imports
|
|
89
|
+
|
|
90
|
+
if pending.empty?
|
|
91
|
+
log "No pending imports. Done."
|
|
92
|
+
break
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
log "Found #{pending.size} pending import(s)"
|
|
96
|
+
|
|
97
|
+
# Find imports whose dependencies are satisfied
|
|
98
|
+
ready = pending.select { |imp| dependencies_satisfied?(imp) }
|
|
99
|
+
|
|
100
|
+
if ready.empty?
|
|
101
|
+
unsatisfied = pending.map(&:name).join(", ")
|
|
102
|
+
raise Archsight::Import::DeadlockError, "Deadlock: pending imports have unsatisfied dependencies: #{unsatisfied}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Sort by priority (lower first), then by name for determinism
|
|
106
|
+
ready.sort_by! { |imp| [imp.annotations["import/priority"].to_i, imp.name] }
|
|
107
|
+
|
|
108
|
+
# Execute all ready imports at the same priority level concurrently
|
|
109
|
+
current_priority = ready.first.annotations["import/priority"].to_i
|
|
110
|
+
batch = ready.select { |imp| imp.annotations["import/priority"].to_i == current_priority }
|
|
111
|
+
|
|
112
|
+
executed_before = @total_executed
|
|
113
|
+
execute_batch_concurrently(batch)
|
|
114
|
+
|
|
115
|
+
# Close shared files before potential database reload so new content is visible
|
|
116
|
+
@shared_writer.close_all
|
|
117
|
+
|
|
118
|
+
# Only reload next iteration if imports were actually executed (not just cached)
|
|
119
|
+
need_reload = @total_executed > executed_before
|
|
120
|
+
|
|
121
|
+
# Stop on first error
|
|
122
|
+
raise Archsight::Import::ImportError, "Import #{@first_error[:name]} failed: #{@first_error[:message]}" if @first_error
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@shared_writer.close_all
|
|
126
|
+
finish_message = build_finish_message
|
|
127
|
+
@concurrent_progress.finish(finish_message) if @total_executed.positive? || @total_cached.positive?
|
|
128
|
+
|
|
129
|
+
# Raise InterruptedError if we were interrupted, so CLI can handle it
|
|
130
|
+
raise Archsight::Import::InterruptedError, "Import interrupted by user" if @interrupted
|
|
131
|
+
ensure
|
|
132
|
+
restore_signal_handlers
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def build_finish_message
|
|
136
|
+
parts = []
|
|
137
|
+
parts << "#{@total_executed} executed" if @total_executed.positive?
|
|
138
|
+
parts << "#{@total_cached} cached" if @total_cached.positive?
|
|
139
|
+
"Completed: #{parts.join(", ")}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Show execution plan without running imports
|
|
143
|
+
# @return [Array<Archsight::Resources::Import>] Imports in execution order
|
|
144
|
+
def execution_plan
|
|
145
|
+
reload_database_quietly!
|
|
146
|
+
|
|
147
|
+
# Collect all imports
|
|
148
|
+
all_imports = database.instances_by_kind("Import")&.values || []
|
|
149
|
+
|
|
150
|
+
# Apply filter if specified
|
|
151
|
+
filtered_imports = all_imports.select { |imp| import_matches_filter?(imp) }
|
|
152
|
+
|
|
153
|
+
# Topological sort
|
|
154
|
+
sorted = topological_sort(filtered_imports)
|
|
155
|
+
|
|
156
|
+
@output.puts "Filter: #{@filter}" if @filter
|
|
157
|
+
|
|
158
|
+
sorted.each_with_index do |imp, idx|
|
|
159
|
+
enabled = import_enabled?(imp)
|
|
160
|
+
deps = import_dependency_names(imp)
|
|
161
|
+
deps_str = deps.empty? ? "(no dependencies)" : "depends on: #{deps.join(", ")}"
|
|
162
|
+
enabled_str = enabled ? "" : " [DISABLED]"
|
|
163
|
+
@output.puts " #{idx + 1}. #{imp.name}#{enabled_str} #{deps_str}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
sorted
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
# Set up signal handlers for graceful shutdown
|
|
172
|
+
def setup_signal_handlers
|
|
173
|
+
@original_int_handler = Signal.trap("INT") do
|
|
174
|
+
if @interrupted
|
|
175
|
+
# Second Ctrl-C: force exit
|
|
176
|
+
# Use trap-safe method (no mutex) since we're in signal context
|
|
177
|
+
@concurrent_progress&.finish_from_trap("Force quit")
|
|
178
|
+
@shared_writer&.close_all
|
|
179
|
+
exit(130)
|
|
180
|
+
else
|
|
181
|
+
# First Ctrl-C: graceful shutdown
|
|
182
|
+
@interrupted = true
|
|
183
|
+
# Use trap-safe method (no mutex) since we're in signal context
|
|
184
|
+
@concurrent_progress&.interrupt_from_trap("Shutting down gracefully (Ctrl-C again to force quit)...")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Restore original signal handlers
|
|
190
|
+
def restore_signal_handlers
|
|
191
|
+
Signal.trap("INT", @original_int_handler || "DEFAULT")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Count all enabled imports (for overall progress display)
|
|
195
|
+
def count_all_enabled_imports
|
|
196
|
+
imports = database.instances_by_kind("Import")&.values || []
|
|
197
|
+
imports.count { |imp| import_enabled?(imp) && import_matches_filter?(imp) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Get all pending imports (enabled=true, not yet executed this run, matches filter)
|
|
201
|
+
def pending_imports
|
|
202
|
+
imports = database.instances_by_kind("Import")&.values || []
|
|
203
|
+
|
|
204
|
+
@mutex.synchronize do
|
|
205
|
+
imports.select do |imp|
|
|
206
|
+
import_enabled?(imp) && import_matches_filter?(imp) && !@executed_this_run.include?(imp.name)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Check if import is enabled and has a handler
|
|
212
|
+
def import_enabled?(import)
|
|
213
|
+
import.annotations["import/enabled"] != "false" && import.annotations["import/handler"]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Check if import matches the filter pattern (or is a dependency of a matching import)
|
|
217
|
+
def import_matches_filter?(import)
|
|
218
|
+
return true unless @filter_regex
|
|
219
|
+
|
|
220
|
+
# Build the set of imports to run on first call (includes filtered + their dependencies)
|
|
221
|
+
@imports_to_run ||= compute_imports_to_run
|
|
222
|
+
@imports_to_run.include?(import.name)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Compute the full set of imports to run: filtered imports + all their dependencies
|
|
226
|
+
def compute_imports_to_run
|
|
227
|
+
all_imports = database.instances_by_kind("Import")&.values || []
|
|
228
|
+
imports_by_name = all_imports.to_h { |imp| [imp.name, imp] }
|
|
229
|
+
|
|
230
|
+
# Start with imports matching the filter
|
|
231
|
+
matching = all_imports.select { |imp| @filter_regex.match?(imp.name) }.map(&:name)
|
|
232
|
+
to_run = Set.new(matching)
|
|
233
|
+
|
|
234
|
+
# Recursively add dependencies
|
|
235
|
+
queue = matching.dup
|
|
236
|
+
while (name = queue.shift)
|
|
237
|
+
import = imports_by_name[name]
|
|
238
|
+
next unless import
|
|
239
|
+
|
|
240
|
+
dep_names = import_dependency_names(import)
|
|
241
|
+
dep_names.each do |dep_name|
|
|
242
|
+
unless to_run.include?(dep_name)
|
|
243
|
+
to_run.add(dep_name)
|
|
244
|
+
queue << dep_name
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
to_run
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Check if all dependencies of an import are satisfied (executed successfully this run)
|
|
253
|
+
def dependencies_satisfied?(import)
|
|
254
|
+
dep_names = import_dependency_names(import)
|
|
255
|
+
return true if dep_names.empty?
|
|
256
|
+
|
|
257
|
+
@mutex.synchronize do
|
|
258
|
+
dep_names.all? do |dep_name|
|
|
259
|
+
@executed_this_run.include?(dep_name) && !@failed_imports.key?(dep_name)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Get dependency names for an import by finding parents that generate this import
|
|
265
|
+
# Dependencies are derived from the inverse of `generates` - if Import A generates Import B,
|
|
266
|
+
# then B depends on A
|
|
267
|
+
def import_dependency_names(import)
|
|
268
|
+
# Find all imports that have this import in their generates.imports
|
|
269
|
+
all_imports = database.instances_by_kind("Import")&.values || []
|
|
270
|
+
parent_imports = all_imports.select do |parent|
|
|
271
|
+
generated = parent.relations(:generates, :imports)
|
|
272
|
+
generated&.any? { |gen| (gen.is_a?(String) ? gen : gen.name) == import.name }
|
|
273
|
+
end
|
|
274
|
+
parent_imports.map(&:name)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Reload database without verbose output
|
|
278
|
+
def reload_database_quietly!
|
|
279
|
+
original_verbose = database.verbose
|
|
280
|
+
database.verbose = false
|
|
281
|
+
database.reload!
|
|
282
|
+
# Reset filter cache since new imports may have been discovered
|
|
283
|
+
@imports_to_run = nil if @filter_regex
|
|
284
|
+
ensure
|
|
285
|
+
database.verbose = original_verbose
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Reload database and update progress total for newly discovered imports
|
|
289
|
+
def reload_and_update_total!
|
|
290
|
+
reload_database_quietly!
|
|
291
|
+
new_total = count_all_enabled_imports
|
|
292
|
+
@concurrent_progress.update_total(new_total) if new_total.positive?
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Execute a batch of imports concurrently
|
|
296
|
+
def execute_batch_concurrently(batch)
|
|
297
|
+
threads = []
|
|
298
|
+
|
|
299
|
+
batch.each do |import|
|
|
300
|
+
# Mark as executed (thread-safe)
|
|
301
|
+
@mutex.synchronize { @executed_this_run.add(import.name) }
|
|
302
|
+
|
|
303
|
+
threads << Thread.new(import) do |imp|
|
|
304
|
+
# Acquire a slot (blocks if all slots are in use)
|
|
305
|
+
slot_progress = @concurrent_progress.acquire_slot(imp.name)
|
|
306
|
+
|
|
307
|
+
begin
|
|
308
|
+
result = execute_single_import(imp, slot_progress)
|
|
309
|
+
if result == :cached
|
|
310
|
+
slot_progress.complete("Cached")
|
|
311
|
+
else
|
|
312
|
+
@mutex.synchronize { @total_executed += 1 }
|
|
313
|
+
slot_progress.complete("Done")
|
|
314
|
+
end
|
|
315
|
+
rescue StandardError => e
|
|
316
|
+
@mutex.synchronize do
|
|
317
|
+
@failed_imports[imp.name] = e.message
|
|
318
|
+
@first_error ||= { name: imp.name, message: e.message }
|
|
319
|
+
end
|
|
320
|
+
slot_progress.error(e.message)
|
|
321
|
+
ensure
|
|
322
|
+
# Update overall progress and release slot
|
|
323
|
+
@concurrent_progress.increment_completed
|
|
324
|
+
slot_progress.release
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Wait for all threads to complete
|
|
330
|
+
threads.each(&:join)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Execute a single import (called from thread)
|
|
334
|
+
# @return [Symbol] :executed, :cached, or raises on error
|
|
335
|
+
def execute_single_import(import, import_progress)
|
|
336
|
+
# Check if import is still fresh based on generated/at and import/cacheTime
|
|
337
|
+
if import_fresh?(import)
|
|
338
|
+
@mutex.synchronize { @total_cached += 1 }
|
|
339
|
+
return :cached
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Execute the import
|
|
343
|
+
handler_class = Archsight::Import::Registry.handler_for(import)
|
|
344
|
+
handler = handler_class.new(
|
|
345
|
+
import,
|
|
346
|
+
database: database,
|
|
347
|
+
resources_dir: resources_dir,
|
|
348
|
+
progress: import_progress,
|
|
349
|
+
shared_writer: @shared_writer
|
|
350
|
+
)
|
|
351
|
+
handler.execute
|
|
352
|
+
:executed
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Check if import is still fresh (cached)
|
|
356
|
+
# Uses generated/at annotation and import/cacheTime to determine freshness
|
|
357
|
+
# Also validates that output file exists (if specified) and source file hasn't changed
|
|
358
|
+
def import_fresh?(import)
|
|
359
|
+
# Honor force flag - always re-run when forced
|
|
360
|
+
return false if @force
|
|
361
|
+
|
|
362
|
+
cache_time = import.annotations["import/cacheTime"]
|
|
363
|
+
return false if cache_time.nil? || cache_time.empty? || cache_time == "never"
|
|
364
|
+
|
|
365
|
+
# Check if output file exists - if not, cache is invalid
|
|
366
|
+
output_path = import.annotations["import/outputPath"]
|
|
367
|
+
if output_path && !output_path.empty?
|
|
368
|
+
full_path = File.join(resources_dir, output_path)
|
|
369
|
+
return false unless File.exist?(full_path)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
generated_at = import.annotations["generated/at"]
|
|
373
|
+
return false if generated_at.nil? || generated_at.empty?
|
|
374
|
+
|
|
375
|
+
ttl_seconds = parse_duration(cache_time)
|
|
376
|
+
return false unless ttl_seconds
|
|
377
|
+
|
|
378
|
+
last_run = Time.parse(generated_at)
|
|
379
|
+
|
|
380
|
+
# Check if import configuration has changed since last generation
|
|
381
|
+
# Compare stored config hash against current config hash
|
|
382
|
+
stored_hash = import.annotations["generated/configHash"]
|
|
383
|
+
if stored_hash
|
|
384
|
+
current_hash = compute_config_hash(import)
|
|
385
|
+
return false if current_hash != stored_hash
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
Time.now < (last_run + ttl_seconds)
|
|
389
|
+
rescue ArgumentError
|
|
390
|
+
# Invalid time format
|
|
391
|
+
false
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def parse_duration(str)
|
|
395
|
+
return nil if str.nil?
|
|
396
|
+
|
|
397
|
+
DURATION_PATTERNS.each do |pattern, multiplier|
|
|
398
|
+
match = str.match(pattern)
|
|
399
|
+
return match[1].to_i * multiplier if match
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
nil
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Compute a hash of the import's configuration for cache invalidation
|
|
406
|
+
# Includes handler and all import/config/* annotations
|
|
407
|
+
def compute_config_hash(import)
|
|
408
|
+
config_data = {
|
|
409
|
+
handler: import.annotations["import/handler"],
|
|
410
|
+
config: import.annotations.select { |k, _| k.start_with?("import/config/") }.sort.to_h
|
|
411
|
+
}
|
|
412
|
+
Digest::SHA256.hexdigest(config_data.to_json)[0, 16]
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Check if an import failed during this run
|
|
416
|
+
def failed?(import)
|
|
417
|
+
@mutex.synchronize { @failed_imports&.key?(import.name) }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Topological sort of imports by dependencies
|
|
421
|
+
def topological_sort(imports)
|
|
422
|
+
sorted = []
|
|
423
|
+
visited = Set.new
|
|
424
|
+
temp_visited = Set.new
|
|
425
|
+
|
|
426
|
+
imports_by_name = imports.to_h { |imp| [imp.name, imp] }
|
|
427
|
+
|
|
428
|
+
visit = lambda do |import|
|
|
429
|
+
return if visited.include?(import.name)
|
|
430
|
+
|
|
431
|
+
raise Archsight::Import::DeadlockError, "Circular dependency detected involving: #{import.name}" if temp_visited.include?(import.name)
|
|
432
|
+
|
|
433
|
+
temp_visited.add(import.name)
|
|
434
|
+
|
|
435
|
+
# Visit dependencies first (derived from generates relation)
|
|
436
|
+
dep_names = import_dependency_names(import)
|
|
437
|
+
dep_names.each do |dep_name|
|
|
438
|
+
dep = imports_by_name[dep_name]
|
|
439
|
+
visit.call(dep) if dep
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
temp_visited.delete(import.name)
|
|
443
|
+
visited.add(import.name)
|
|
444
|
+
sorted << import
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
imports.each { |imp| visit.call(imp) }
|
|
448
|
+
sorted
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def log(msg)
|
|
452
|
+
# Suppress verbose output in TTY mode as it would disrupt slot-based progress display
|
|
453
|
+
return if @concurrent_progress&.tty?
|
|
454
|
+
|
|
455
|
+
@output.puts msg if verbose
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Error raised when imports have circular or unsatisfied dependencies
|
|
460
|
+
class Archsight::Import::DeadlockError < StandardError; end
|
|
461
|
+
|
|
462
|
+
# Error raised when an import fails
|
|
463
|
+
class Archsight::Import::ImportError < StandardError; end
|
|
464
|
+
|
|
465
|
+
# Error raised when import is interrupted by user (Ctrl-C)
|
|
466
|
+
class Archsight::Import::InterruptedError < StandardError; end
|