aidp 0.25.0 → 0.27.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/README.md +45 -6
- data/lib/aidp/analyze/error_handler.rb +11 -0
- data/lib/aidp/cli/checkpoint_command.rb +198 -0
- data/lib/aidp/cli/config_command.rb +71 -0
- data/lib/aidp/cli/enhanced_input.rb +2 -0
- data/lib/aidp/cli/first_run_wizard.rb +8 -7
- data/lib/aidp/cli/harness_command.rb +102 -0
- data/lib/aidp/cli/jobs_command.rb +3 -3
- data/lib/aidp/cli/mcp_dashboard.rb +4 -3
- data/lib/aidp/cli/models_command.rb +662 -0
- data/lib/aidp/cli/providers_command.rb +223 -0
- data/lib/aidp/cli.rb +35 -456
- data/lib/aidp/daemon/runner.rb +2 -2
- data/lib/aidp/debug_mixin.rb +2 -9
- data/lib/aidp/execute/async_work_loop_runner.rb +2 -1
- data/lib/aidp/execute/checkpoint_display.rb +38 -37
- data/lib/aidp/execute/interactive_repl.rb +2 -1
- data/lib/aidp/execute/prompt_manager.rb +4 -4
- data/lib/aidp/execute/work_loop_runner.rb +253 -56
- data/lib/aidp/execute/workflow_selector.rb +2 -2
- data/lib/aidp/harness/config_loader.rb +20 -11
- data/lib/aidp/harness/config_manager.rb +5 -5
- data/lib/aidp/harness/config_schema.rb +30 -8
- data/lib/aidp/harness/configuration.rb +105 -4
- data/lib/aidp/harness/enhanced_runner.rb +24 -15
- data/lib/aidp/harness/error_handler.rb +26 -5
- data/lib/aidp/harness/filter_strategy.rb +45 -0
- data/lib/aidp/harness/generic_filter_strategy.rb +63 -0
- data/lib/aidp/harness/model_cache.rb +269 -0
- data/lib/aidp/harness/model_discovery_service.rb +259 -0
- data/lib/aidp/harness/model_registry.rb +201 -0
- data/lib/aidp/harness/output_filter.rb +136 -0
- data/lib/aidp/harness/provider_manager.rb +18 -3
- data/lib/aidp/harness/rspec_filter_strategy.rb +82 -0
- data/lib/aidp/harness/runner.rb +5 -0
- data/lib/aidp/harness/test_runner.rb +165 -27
- data/lib/aidp/harness/thinking_depth_manager.rb +223 -7
- data/lib/aidp/harness/ui/enhanced_tui.rb +4 -1
- data/lib/aidp/logger.rb +35 -5
- data/lib/aidp/providers/adapter.rb +2 -4
- data/lib/aidp/providers/anthropic.rb +141 -128
- data/lib/aidp/providers/base.rb +98 -2
- data/lib/aidp/providers/capability_registry.rb +0 -1
- data/lib/aidp/providers/codex.rb +49 -67
- data/lib/aidp/providers/cursor.rb +71 -59
- data/lib/aidp/providers/gemini.rb +44 -60
- data/lib/aidp/providers/github_copilot.rb +2 -66
- data/lib/aidp/providers/kilocode.rb +24 -80
- data/lib/aidp/providers/opencode.rb +24 -80
- data/lib/aidp/safe_directory.rb +10 -3
- data/lib/aidp/setup/wizard.rb +345 -8
- data/lib/aidp/storage/csv_storage.rb +9 -3
- data/lib/aidp/storage/file_manager.rb +8 -2
- data/lib/aidp/storage/json_storage.rb +9 -3
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/build_processor.rb +40 -1
- data/lib/aidp/watch/change_request_processor.rb +659 -0
- data/lib/aidp/watch/plan_generator.rb +93 -14
- data/lib/aidp/watch/plan_processor.rb +71 -8
- data/lib/aidp/watch/repository_client.rb +85 -20
- data/lib/aidp/watch/review_processor.rb +3 -3
- data/lib/aidp/watch/runner.rb +37 -0
- data/lib/aidp/watch/state_store.rb +46 -1
- data/lib/aidp/workflows/guided_agent.rb +3 -3
- data/lib/aidp/workstream_executor.rb +5 -2
- data/lib/aidp.rb +4 -0
- data/templates/aidp-development.yml.example +2 -2
- data/templates/aidp-production.yml.example +3 -3
- data/templates/aidp.yml.example +53 -0
- metadata +14 -1
|
@@ -168,12 +168,47 @@ module Aidp
|
|
|
168
168
|
|
|
169
169
|
# Get test commands
|
|
170
170
|
def test_commands
|
|
171
|
-
work_loop_config[:test_commands] || []
|
|
171
|
+
normalize_commands(work_loop_config[:test_commands] || [])
|
|
172
172
|
end
|
|
173
173
|
|
|
174
174
|
# Get lint commands
|
|
175
175
|
def lint_commands
|
|
176
|
-
work_loop_config[:lint_commands] || []
|
|
176
|
+
normalize_commands(work_loop_config[:lint_commands] || [])
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Get formatter commands
|
|
180
|
+
def formatter_commands
|
|
181
|
+
normalize_commands(work_loop_config[:formatter_commands] || [])
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Get build commands
|
|
185
|
+
def build_commands
|
|
186
|
+
normalize_commands(work_loop_config[:build_commands] || [])
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Get documentation commands
|
|
190
|
+
def documentation_commands
|
|
191
|
+
normalize_commands(work_loop_config[:documentation_commands] || [])
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Get test output mode
|
|
195
|
+
def test_output_mode
|
|
196
|
+
work_loop_config.dig(:test, :output_mode) || :full
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Get max output lines for tests
|
|
200
|
+
def test_max_output_lines
|
|
201
|
+
work_loop_config.dig(:test, :max_output_lines) || 500
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Get lint output mode
|
|
205
|
+
def lint_output_mode
|
|
206
|
+
work_loop_config.dig(:lint, :output_mode) || :full
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Get max output lines for linters
|
|
210
|
+
def lint_max_output_lines
|
|
211
|
+
work_loop_config.dig(:lint, :max_output_lines) || 300
|
|
177
212
|
end
|
|
178
213
|
|
|
179
214
|
# Get guards configuration
|
|
@@ -348,12 +383,12 @@ module Aidp
|
|
|
348
383
|
|
|
349
384
|
# Get default thinking tier
|
|
350
385
|
def default_tier
|
|
351
|
-
thinking_config[:default_tier] ||
|
|
386
|
+
thinking_config[:default_tier] || default_thinking_config[:default_tier]
|
|
352
387
|
end
|
|
353
388
|
|
|
354
389
|
# Get maximum thinking tier
|
|
355
390
|
def max_tier
|
|
356
|
-
thinking_config[:max_tier] ||
|
|
391
|
+
thinking_config[:max_tier] || default_thinking_config[:max_tier]
|
|
357
392
|
end
|
|
358
393
|
|
|
359
394
|
# Check if provider switching for tier is allowed
|
|
@@ -397,6 +432,36 @@ module Aidp
|
|
|
397
432
|
thinking_overrides[key] || thinking_overrides[key.to_sym]
|
|
398
433
|
end
|
|
399
434
|
|
|
435
|
+
# Get tiers configuration from user's config
|
|
436
|
+
def thinking_tiers_config
|
|
437
|
+
thinking_config[:tiers] || {}
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Get models configured for a specific tier
|
|
441
|
+
# Returns array of {provider:, model:} hashes
|
|
442
|
+
def models_for_tier(tier)
|
|
443
|
+
tier_config = thinking_tiers_config[tier] || thinking_tiers_config[tier.to_sym]
|
|
444
|
+
return [] unless tier_config
|
|
445
|
+
|
|
446
|
+
models = tier_config[:models] || tier_config["models"]
|
|
447
|
+
return [] unless models
|
|
448
|
+
|
|
449
|
+
# Normalize to array of hashes with symbol keys
|
|
450
|
+
Array(models).map do |model_entry|
|
|
451
|
+
if model_entry.is_a?(Hash)
|
|
452
|
+
{
|
|
453
|
+
provider: (model_entry[:provider] || model_entry["provider"]).to_s,
|
|
454
|
+
model: (model_entry[:model] || model_entry["model"]).to_s
|
|
455
|
+
}
|
|
456
|
+
end
|
|
457
|
+
end.compact
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Get all configured tiers
|
|
461
|
+
def configured_tiers
|
|
462
|
+
thinking_tiers_config.keys.map(&:to_s)
|
|
463
|
+
end
|
|
464
|
+
|
|
400
465
|
# Get fallback configuration
|
|
401
466
|
def fallback_config
|
|
402
467
|
harness_config[:fallback] || default_fallback_config
|
|
@@ -704,6 +769,42 @@ module Aidp
|
|
|
704
769
|
|
|
705
770
|
private
|
|
706
771
|
|
|
772
|
+
# Normalize command configuration to consistent format
|
|
773
|
+
# Supports both string format and object format with required flag
|
|
774
|
+
# Examples:
|
|
775
|
+
# "bundle exec rspec" -> {command: "bundle exec rspec", required: true}
|
|
776
|
+
# {command: "rubocop", required: false} -> {command: "rubocop", required: false}
|
|
777
|
+
def normalize_commands(commands)
|
|
778
|
+
return [] if commands.nil? || commands.empty?
|
|
779
|
+
|
|
780
|
+
commands.map do |cmd|
|
|
781
|
+
case cmd
|
|
782
|
+
when String
|
|
783
|
+
{command: cmd, required: true}
|
|
784
|
+
when Hash
|
|
785
|
+
# Handle both symbol and string keys
|
|
786
|
+
command_value = cmd[:command] || cmd["command"]
|
|
787
|
+
required_value = if cmd.key?(:required)
|
|
788
|
+
cmd[:required]
|
|
789
|
+
else
|
|
790
|
+
(cmd.key?("required") ? cmd["required"] : true)
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
unless command_value.is_a?(String) && !command_value.empty?
|
|
794
|
+
raise ConfigurationError, "Command must be a non-empty string, got: #{command_value.inspect}"
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
unless [true, false].include?(required_value)
|
|
798
|
+
raise ConfigurationError, "Required flag must be boolean, got: #{required_value.inspect}"
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
{command: command_value, required: required_value}
|
|
802
|
+
else
|
|
803
|
+
raise ConfigurationError, "Command must be a string or hash, got: #{cmd.class}"
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
end
|
|
807
|
+
|
|
707
808
|
def validate_configuration!
|
|
708
809
|
errors = Aidp::Config.validate_harness_config(@config, @project_dir)
|
|
709
810
|
|
|
@@ -6,6 +6,7 @@ require_relative "ui/job_monitor"
|
|
|
6
6
|
require_relative "ui/workflow_controller"
|
|
7
7
|
require_relative "ui/progress_display"
|
|
8
8
|
require_relative "ui/status_widget"
|
|
9
|
+
require_relative "../errors"
|
|
9
10
|
|
|
10
11
|
module Aidp
|
|
11
12
|
module Harness
|
|
@@ -47,27 +48,31 @@ module Aidp
|
|
|
47
48
|
@selected_steps = options[:selected_steps] || []
|
|
48
49
|
@workflow_type = options[:workflow_type] || :default
|
|
49
50
|
|
|
50
|
-
# Initialize enhanced TUI components
|
|
51
|
-
@tui = UI::EnhancedTUI.new
|
|
52
|
-
@workflow_selector = UI::EnhancedWorkflowSelector.new(@tui)
|
|
53
|
-
@job_monitor = UI::JobMonitor.new
|
|
54
|
-
@workflow_controller = UI::WorkflowController.new
|
|
55
|
-
@progress_display = UI::ProgressDisplay.new
|
|
56
|
-
@status_widget = UI::StatusWidget.new
|
|
51
|
+
# Initialize enhanced TUI components (with dependency injection)
|
|
52
|
+
@tui = options[:tui] || UI::EnhancedTUI.new
|
|
53
|
+
@workflow_selector = options[:workflow_selector] || UI::EnhancedWorkflowSelector.new(@tui)
|
|
54
|
+
@job_monitor = options[:job_monitor] || UI::JobMonitor.new
|
|
55
|
+
@workflow_controller = options[:workflow_controller] || UI::WorkflowController.new
|
|
56
|
+
@progress_display = options[:progress_display] || UI::ProgressDisplay.new
|
|
57
|
+
@status_widget = options[:status_widget] || UI::StatusWidget.new
|
|
57
58
|
|
|
58
|
-
# Initialize other components
|
|
59
|
-
@configuration = Configuration.new(project_dir)
|
|
60
|
-
@state_manager = StateManager.new(project_dir, @mode)
|
|
61
|
-
@provider_manager = ProviderManager.new(@configuration, prompt: @prompt)
|
|
59
|
+
# Initialize other components (with dependency injection)
|
|
60
|
+
@configuration = options[:configuration] || Configuration.new(project_dir)
|
|
61
|
+
@state_manager = options[:state_manager] || StateManager.new(project_dir, @mode)
|
|
62
|
+
@provider_manager = options[:provider_manager] || ProviderManager.new(@configuration, prompt: @prompt)
|
|
62
63
|
|
|
63
64
|
# Use ZFC-enabled condition detector
|
|
64
65
|
# ZfcConditionDetector will create its own ProviderFactory if needed
|
|
65
66
|
# Falls back to legacy pattern matching when ZFC is disabled
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
if options[:condition_detector]
|
|
68
|
+
@condition_detector = options[:condition_detector]
|
|
69
|
+
else
|
|
70
|
+
require_relative "zfc_condition_detector"
|
|
71
|
+
@condition_detector = ZfcConditionDetector.new(@configuration)
|
|
72
|
+
end
|
|
68
73
|
|
|
69
|
-
@error_handler = ErrorHandler.new(@provider_manager, @configuration)
|
|
70
|
-
@completion_checker = CompletionChecker.new(@project_dir, @workflow_type)
|
|
74
|
+
@error_handler = options[:error_handler] || ErrorHandler.new(@provider_manager, @configuration)
|
|
75
|
+
@completion_checker = options[:completion_checker] || CompletionChecker.new(@project_dir, @workflow_type)
|
|
71
76
|
end
|
|
72
77
|
|
|
73
78
|
# Get current provider (delegate to provider manager)
|
|
@@ -158,6 +163,10 @@ module Aidp
|
|
|
158
163
|
handle_completion_criteria_not_met(completion_status)
|
|
159
164
|
end
|
|
160
165
|
end
|
|
166
|
+
rescue Aidp::Errors::ConfigurationError
|
|
167
|
+
# Configuration errors should crash immediately (crash-early principle)
|
|
168
|
+
# Re-raise without catching
|
|
169
|
+
raise
|
|
161
170
|
rescue => e
|
|
162
171
|
@state = STATES[:error]
|
|
163
172
|
# Single error message - don't duplicate
|
|
@@ -4,6 +4,7 @@ require "net/http"
|
|
|
4
4
|
require_relative "../debug_mixin"
|
|
5
5
|
require_relative "../concurrency"
|
|
6
6
|
require_relative "../providers/error_taxonomy"
|
|
7
|
+
require_relative "../errors"
|
|
7
8
|
|
|
8
9
|
module Aidp
|
|
9
10
|
module Harness
|
|
@@ -107,6 +108,10 @@ module Aidp
|
|
|
107
108
|
begin
|
|
108
109
|
attempt += 1
|
|
109
110
|
return yield
|
|
111
|
+
rescue Aidp::Errors::ConfigurationError
|
|
112
|
+
# Configuration errors should crash immediately (crash-early principle)
|
|
113
|
+
# Re-raise without catching
|
|
114
|
+
raise
|
|
110
115
|
rescue => error
|
|
111
116
|
current_provider = current_provider_safely
|
|
112
117
|
|
|
@@ -483,7 +488,7 @@ module Aidp
|
|
|
483
488
|
}
|
|
484
489
|
end
|
|
485
490
|
|
|
486
|
-
def attempt_provider_switch(error_info,
|
|
491
|
+
def attempt_provider_switch(error_info, recovery_plan)
|
|
487
492
|
new_provider = @provider_manager.switch_provider_for_error(
|
|
488
493
|
error_info[:error_type],
|
|
489
494
|
error_info[:context]
|
|
@@ -497,6 +502,18 @@ module Aidp
|
|
|
497
502
|
reason: "Error recovery: #{error_info[:error_type]}"
|
|
498
503
|
}
|
|
499
504
|
else
|
|
505
|
+
# If this is an auth error and we have no fallback providers, crash
|
|
506
|
+
if recovery_plan[:crash_if_no_fallback]
|
|
507
|
+
error_msg = "All providers have failed authentication.\n\n" \
|
|
508
|
+
"Last provider: #{error_info[:provider]}\n" \
|
|
509
|
+
"Error: #{error_info[:error]&.message || error_info[:error]}\n\n" \
|
|
510
|
+
"Please check your API credentials for all configured providers.\n" \
|
|
511
|
+
"Run 'aidp config --interactive' to update credentials."
|
|
512
|
+
|
|
513
|
+
raise Aidp::Errors::ConfigurationError, error_msg
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# For non-auth errors, return failure result
|
|
500
517
|
{
|
|
501
518
|
success: false,
|
|
502
519
|
action: :provider_switch_failed,
|
|
@@ -680,12 +697,13 @@ module Aidp
|
|
|
680
697
|
priority: :high
|
|
681
698
|
}
|
|
682
699
|
when :auth_expired
|
|
683
|
-
#
|
|
684
|
-
#
|
|
700
|
+
# Try to switch to another provider. If no providers available, this will
|
|
701
|
+
# be detected in attempt_recovery and we'll crash (crash-early principle)
|
|
685
702
|
{
|
|
686
703
|
action: :switch_provider,
|
|
687
704
|
reason: "Authentication expired – switching provider to continue",
|
|
688
|
-
priority: :critical
|
|
705
|
+
priority: :critical,
|
|
706
|
+
crash_if_no_fallback: true
|
|
689
707
|
}
|
|
690
708
|
when :quota_exceeded
|
|
691
709
|
{
|
|
@@ -725,10 +743,13 @@ module Aidp
|
|
|
725
743
|
priority: :medium
|
|
726
744
|
}
|
|
727
745
|
when :authentication, :permission_denied
|
|
746
|
+
# Try to switch to another provider. If no providers available, this will
|
|
747
|
+
# be detected in attempt_recovery and we'll crash (crash-early principle)
|
|
728
748
|
{
|
|
729
749
|
action: :switch_provider,
|
|
730
750
|
reason: "Authentication/permission issue – switching provider to continue",
|
|
731
|
-
priority: :critical
|
|
751
|
+
priority: :critical,
|
|
752
|
+
crash_if_no_fallback: true
|
|
732
753
|
}
|
|
733
754
|
when :rate_limit
|
|
734
755
|
{
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aidp
|
|
4
|
+
module Harness
|
|
5
|
+
# Base class for framework-specific filtering strategies
|
|
6
|
+
class FilterStrategy
|
|
7
|
+
# @param output [String] Raw output
|
|
8
|
+
# @param filter [OutputFilter] Filter instance for config access
|
|
9
|
+
# @return [String] Filtered output
|
|
10
|
+
def filter(output, filter_instance)
|
|
11
|
+
raise NotImplementedError, "Subclasses must implement #filter"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
protected
|
|
15
|
+
|
|
16
|
+
# Extract lines around a match (for context)
|
|
17
|
+
def extract_with_context(lines, index, context_lines)
|
|
18
|
+
start_idx = [0, index - context_lines].max
|
|
19
|
+
end_idx = [lines.length - 1, index + context_lines].min
|
|
20
|
+
|
|
21
|
+
lines[start_idx..end_idx]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Find failure markers in output
|
|
25
|
+
def find_failure_markers(output)
|
|
26
|
+
lines = output.lines
|
|
27
|
+
markers = []
|
|
28
|
+
|
|
29
|
+
lines.each_with_index do |line, index|
|
|
30
|
+
# Check for failure patterns using safe string methods
|
|
31
|
+
if line.match?(/FAILED/i) ||
|
|
32
|
+
line.match?(/ERROR/i) ||
|
|
33
|
+
line.match?(/FAIL:/i) ||
|
|
34
|
+
line.match?(/failures?:/i) ||
|
|
35
|
+
line.match?(/^\s*\d{1,4}\)\s/) || # Numbered failures (limit digits to prevent ReDoS)
|
|
36
|
+
line.include?(") ") # Additional simple check for numbered patterns
|
|
37
|
+
markers << index
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
markers
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "filter_strategy"
|
|
4
|
+
|
|
5
|
+
module Aidp
|
|
6
|
+
module Harness
|
|
7
|
+
# Generic filtering for unknown frameworks
|
|
8
|
+
class GenericFilterStrategy < FilterStrategy
|
|
9
|
+
def filter(output, filter_instance)
|
|
10
|
+
case filter_instance.mode
|
|
11
|
+
when :failures_only
|
|
12
|
+
extract_failure_lines(output, filter_instance)
|
|
13
|
+
when :minimal
|
|
14
|
+
extract_summary(output, filter_instance)
|
|
15
|
+
else
|
|
16
|
+
output
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def extract_failure_lines(output, filter_instance)
|
|
23
|
+
lines = output.lines
|
|
24
|
+
failure_indices = find_failure_markers(output)
|
|
25
|
+
|
|
26
|
+
return output if failure_indices.empty?
|
|
27
|
+
|
|
28
|
+
# Extract failures with context
|
|
29
|
+
relevant_lines = Set.new
|
|
30
|
+
failure_indices.each do |index|
|
|
31
|
+
if filter_instance.include_context
|
|
32
|
+
extract_with_context(lines, index, filter_instance.context_lines)
|
|
33
|
+
start_idx = [0, index - filter_instance.context_lines].max
|
|
34
|
+
end_idx = [lines.length - 1, index + filter_instance.context_lines].min
|
|
35
|
+
(start_idx..end_idx).each { |idx| relevant_lines.add(idx) }
|
|
36
|
+
else
|
|
37
|
+
relevant_lines.add(index)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
selected = relevant_lines.to_a.sort.map { |idx| lines[idx] }
|
|
42
|
+
selected.join
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def extract_summary(output, filter_instance)
|
|
46
|
+
lines = output.lines
|
|
47
|
+
|
|
48
|
+
# Take first line, last line, and any lines with numbers/statistics
|
|
49
|
+
parts = []
|
|
50
|
+
parts << lines.first if lines.first
|
|
51
|
+
|
|
52
|
+
summary_lines = lines.select do |line|
|
|
53
|
+
line.match?(/\d+/) || line.match?(/summary|total|passed|failed/i)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
parts.concat(summary_lines.uniq)
|
|
57
|
+
parts << lines.last if lines.last && !parts.include?(lines.last)
|
|
58
|
+
|
|
59
|
+
parts.join("\n")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Aidp
|
|
7
|
+
module Harness
|
|
8
|
+
# Manages caching of discovered models with TTL support
|
|
9
|
+
#
|
|
10
|
+
# Cache is stored in ~/.aidp/cache/models.json
|
|
11
|
+
# Each provider's models are cached separately with timestamps
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# cache = ModelCache.new
|
|
15
|
+
# cache.cache_models("anthropic", models_array)
|
|
16
|
+
# cached = cache.get_cached_models("anthropic")
|
|
17
|
+
# cache.invalidate("anthropic")
|
|
18
|
+
class ModelCache
|
|
19
|
+
class CacheError < StandardError; end
|
|
20
|
+
|
|
21
|
+
DEFAULT_TTL = 86400 # 24 hours in seconds
|
|
22
|
+
|
|
23
|
+
attr_reader :cache_file
|
|
24
|
+
|
|
25
|
+
def initialize(cache_file: nil, cache_dir: nil)
|
|
26
|
+
@cache_file = determine_cache_file(cache_file, cache_dir)
|
|
27
|
+
@cache_enabled = ensure_cache_directory
|
|
28
|
+
|
|
29
|
+
if @cache_enabled
|
|
30
|
+
Aidp.log_debug("model_cache", "initialized", cache_file: @cache_file)
|
|
31
|
+
else
|
|
32
|
+
Aidp.log_warn("model_cache", "cache disabled due to permission issues")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Get cached models for a provider if not expired
|
|
37
|
+
#
|
|
38
|
+
# @param provider [String] Provider name
|
|
39
|
+
# @return [Array<Hash>, nil] Cached models or nil if expired/not found
|
|
40
|
+
def get_cached_models(provider)
|
|
41
|
+
cache_data = load_cache
|
|
42
|
+
provider_cache = cache_data[provider]
|
|
43
|
+
|
|
44
|
+
return nil unless provider_cache
|
|
45
|
+
|
|
46
|
+
cached_at = begin
|
|
47
|
+
Time.parse(provider_cache["cached_at"])
|
|
48
|
+
rescue
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
return nil unless cached_at
|
|
52
|
+
|
|
53
|
+
ttl = provider_cache["ttl"] || DEFAULT_TTL
|
|
54
|
+
expires_at = cached_at + ttl
|
|
55
|
+
|
|
56
|
+
if Time.now > expires_at
|
|
57
|
+
Aidp.log_debug("model_cache", "cache expired",
|
|
58
|
+
provider: provider, cached_at: cached_at, expires_at: expires_at)
|
|
59
|
+
return nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
models = provider_cache["models"]
|
|
63
|
+
# Convert string keys to symbols for consistency with fresh discovery
|
|
64
|
+
models = models.map { |m| m.transform_keys(&:to_sym) } if models
|
|
65
|
+
Aidp.log_debug("model_cache", "cache hit",
|
|
66
|
+
provider: provider, count: models&.size || 0)
|
|
67
|
+
models
|
|
68
|
+
rescue => e
|
|
69
|
+
Aidp.log_error("model_cache", "failed to read cache",
|
|
70
|
+
provider: provider, error: e.message)
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Cache models for a provider with TTL
|
|
75
|
+
#
|
|
76
|
+
# @param provider [String] Provider name
|
|
77
|
+
# @param models [Array<Hash>] Models to cache
|
|
78
|
+
# @param ttl [Integer] Time to live in seconds (default: 24 hours)
|
|
79
|
+
def cache_models(provider, models, ttl: DEFAULT_TTL)
|
|
80
|
+
unless @cache_enabled
|
|
81
|
+
Aidp.log_debug("model_cache", "caching disabled, skipping",
|
|
82
|
+
provider: provider)
|
|
83
|
+
return false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
cache_data = load_cache
|
|
87
|
+
|
|
88
|
+
cache_data[provider] = {
|
|
89
|
+
"cached_at" => Time.now.iso8601,
|
|
90
|
+
"ttl" => ttl,
|
|
91
|
+
"models" => models
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if save_cache(cache_data)
|
|
95
|
+
Aidp.log_info("model_cache", "cached models",
|
|
96
|
+
provider: provider, count: models.size, ttl: ttl)
|
|
97
|
+
true
|
|
98
|
+
else
|
|
99
|
+
Aidp.log_warn("model_cache", "failed to cache models",
|
|
100
|
+
provider: provider)
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
rescue => e
|
|
104
|
+
Aidp.log_error("model_cache", "error caching models",
|
|
105
|
+
provider: provider, error: e.message)
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Invalidate cache for a specific provider
|
|
110
|
+
#
|
|
111
|
+
# @param provider [String] Provider name
|
|
112
|
+
def invalidate(provider)
|
|
113
|
+
return false unless @cache_enabled
|
|
114
|
+
|
|
115
|
+
cache_data = load_cache
|
|
116
|
+
cache_data.delete(provider)
|
|
117
|
+
save_cache(cache_data)
|
|
118
|
+
Aidp.log_info("model_cache", "invalidated cache", provider: provider)
|
|
119
|
+
true
|
|
120
|
+
rescue => e
|
|
121
|
+
Aidp.log_error("model_cache", "failed to invalidate cache",
|
|
122
|
+
provider: provider, error: e.message)
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Invalidate all cached models
|
|
127
|
+
def invalidate_all
|
|
128
|
+
return false unless @cache_enabled
|
|
129
|
+
|
|
130
|
+
save_cache({})
|
|
131
|
+
Aidp.log_info("model_cache", "invalidated all caches")
|
|
132
|
+
true
|
|
133
|
+
rescue => e
|
|
134
|
+
Aidp.log_error("model_cache", "failed to invalidate all",
|
|
135
|
+
error: e.message)
|
|
136
|
+
false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Get list of providers with cached models
|
|
140
|
+
#
|
|
141
|
+
# @return [Array<String>] Provider names with valid caches
|
|
142
|
+
def cached_providers
|
|
143
|
+
cache_data = load_cache
|
|
144
|
+
providers = []
|
|
145
|
+
|
|
146
|
+
cache_data.each do |provider, data|
|
|
147
|
+
cached_at = begin
|
|
148
|
+
Time.parse(data["cached_at"])
|
|
149
|
+
rescue
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
next unless cached_at
|
|
153
|
+
|
|
154
|
+
ttl = data["ttl"] || DEFAULT_TTL
|
|
155
|
+
expires_at = cached_at + ttl
|
|
156
|
+
|
|
157
|
+
providers << provider if Time.now <= expires_at
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
providers
|
|
161
|
+
rescue => e
|
|
162
|
+
Aidp.log_error("model_cache", "failed to get cached providers",
|
|
163
|
+
error: e.message)
|
|
164
|
+
[]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Get cache statistics
|
|
168
|
+
#
|
|
169
|
+
# @return [Hash] Statistics about the cache
|
|
170
|
+
def stats
|
|
171
|
+
cache_data = load_cache
|
|
172
|
+
file_size = begin
|
|
173
|
+
File.size(@cache_file)
|
|
174
|
+
rescue
|
|
175
|
+
0
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
{
|
|
179
|
+
total_providers: cache_data.size,
|
|
180
|
+
cached_providers: cached_providers,
|
|
181
|
+
cache_file_size: file_size
|
|
182
|
+
}
|
|
183
|
+
rescue => e
|
|
184
|
+
Aidp.log_error("model_cache", "failed to get stats",
|
|
185
|
+
error: e.message)
|
|
186
|
+
{total_providers: 0, cached_providers: [], cache_file_size: 0}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def determine_cache_file(cache_file, cache_dir)
|
|
192
|
+
return cache_file if cache_file
|
|
193
|
+
|
|
194
|
+
if cache_dir
|
|
195
|
+
File.join(cache_dir, "models.json")
|
|
196
|
+
else
|
|
197
|
+
default_cache_file
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def default_cache_file
|
|
202
|
+
File.join(Dir.home, ".aidp", "cache", "models.json")
|
|
203
|
+
rescue => e
|
|
204
|
+
# Fallback to temp directory if home directory is not accessible
|
|
205
|
+
Aidp.log_debug("model_cache", "home directory not accessible, using temp",
|
|
206
|
+
error: e.message)
|
|
207
|
+
File.join(Dir.tmpdir, "aidp_cache", "models.json")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def ensure_cache_directory
|
|
211
|
+
cache_dir = File.dirname(@cache_file)
|
|
212
|
+
return true if File.directory?(cache_dir)
|
|
213
|
+
|
|
214
|
+
FileUtils.mkdir_p(cache_dir)
|
|
215
|
+
true
|
|
216
|
+
rescue Errno::EACCES, Errno::EPERM => e
|
|
217
|
+
Aidp.log_warn("model_cache", "permission denied creating cache directory",
|
|
218
|
+
cache_dir: cache_dir, error: e.message)
|
|
219
|
+
|
|
220
|
+
# Try fallback to temp directory
|
|
221
|
+
@cache_file = File.join(Dir.tmpdir, "aidp_cache", "models.json")
|
|
222
|
+
fallback_dir = File.dirname(@cache_file)
|
|
223
|
+
|
|
224
|
+
begin
|
|
225
|
+
FileUtils.mkdir_p(fallback_dir) unless File.directory?(fallback_dir)
|
|
226
|
+
Aidp.log_info("model_cache", "using fallback cache directory",
|
|
227
|
+
cache_file: @cache_file)
|
|
228
|
+
true
|
|
229
|
+
rescue => fallback_error
|
|
230
|
+
Aidp.log_error("model_cache", "failed to create fallback cache directory",
|
|
231
|
+
error: fallback_error.message)
|
|
232
|
+
false
|
|
233
|
+
end
|
|
234
|
+
rescue => e
|
|
235
|
+
Aidp.log_error("model_cache", "failed to create cache directory",
|
|
236
|
+
cache_dir: cache_dir, error: e.message)
|
|
237
|
+
false
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def load_cache
|
|
241
|
+
return {} unless File.exist?(@cache_file)
|
|
242
|
+
|
|
243
|
+
content = File.read(@cache_file)
|
|
244
|
+
JSON.parse(content)
|
|
245
|
+
rescue JSON::ParserError => e
|
|
246
|
+
Aidp.log_warn("model_cache", "corrupted cache file, resetting",
|
|
247
|
+
error: e.message)
|
|
248
|
+
# Reset corrupted cache
|
|
249
|
+
{}
|
|
250
|
+
rescue => e
|
|
251
|
+
Aidp.log_error("model_cache", "failed to load cache",
|
|
252
|
+
error: e.message)
|
|
253
|
+
{}
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def save_cache(data)
|
|
257
|
+
return false unless @cache_enabled
|
|
258
|
+
|
|
259
|
+
ensure_cache_directory
|
|
260
|
+
File.write(@cache_file, JSON.pretty_generate(data))
|
|
261
|
+
true
|
|
262
|
+
rescue => e
|
|
263
|
+
Aidp.log_error("model_cache", "failed to save cache",
|
|
264
|
+
error: e.message, cache_file: @cache_file)
|
|
265
|
+
false
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|