aidp 0.23.0 → 0.24.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/aidp/cli.rb +3 -0
  3. data/lib/aidp/execute/work_loop_runner.rb +252 -45
  4. data/lib/aidp/execute/work_loop_unit_scheduler.rb +27 -2
  5. data/lib/aidp/harness/condition_detector.rb +42 -8
  6. data/lib/aidp/harness/config_manager.rb +7 -0
  7. data/lib/aidp/harness/config_schema.rb +25 -0
  8. data/lib/aidp/harness/configuration.rb +69 -6
  9. data/lib/aidp/harness/error_handler.rb +117 -44
  10. data/lib/aidp/harness/provider_manager.rb +64 -0
  11. data/lib/aidp/harness/provider_metrics.rb +138 -0
  12. data/lib/aidp/harness/runner.rb +90 -29
  13. data/lib/aidp/harness/simple_user_interface.rb +4 -0
  14. data/lib/aidp/harness/state/ui_state.rb +0 -10
  15. data/lib/aidp/harness/state_manager.rb +1 -15
  16. data/lib/aidp/harness/test_runner.rb +39 -2
  17. data/lib/aidp/logger.rb +34 -4
  18. data/lib/aidp/providers/adapter.rb +241 -0
  19. data/lib/aidp/providers/anthropic.rb +75 -7
  20. data/lib/aidp/providers/base.rb +29 -1
  21. data/lib/aidp/providers/capability_registry.rb +205 -0
  22. data/lib/aidp/providers/codex.rb +14 -0
  23. data/lib/aidp/providers/error_taxonomy.rb +195 -0
  24. data/lib/aidp/providers/gemini.rb +3 -2
  25. data/lib/aidp/setup/provider_registry.rb +107 -0
  26. data/lib/aidp/setup/wizard.rb +115 -31
  27. data/lib/aidp/version.rb +1 -1
  28. data/lib/aidp/watch/build_processor.rb +263 -23
  29. data/lib/aidp/watch/repository_client.rb +4 -4
  30. data/lib/aidp/watch/runner.rb +37 -5
  31. data/lib/aidp/workflows/guided_agent.rb +53 -0
  32. data/lib/aidp/worktree.rb +67 -10
  33. data/templates/work_loop/decide_whats_next.md +21 -0
  34. data/templates/work_loop/diagnose_failures.md +21 -0
  35. metadata +10 -3
  36. /data/{bin → exe}/aidp +0 -0
@@ -7,6 +7,8 @@ module Aidp
7
7
  module Harness
8
8
  # Handles loading and validation of harness configuration from aidp.yml
9
9
  class Configuration
10
+ attr_reader :project_dir
11
+
10
12
  def initialize(project_dir)
11
13
  @project_dir = project_dir
12
14
  @config = Aidp::Config.load_harness_config(project_dir)
@@ -600,7 +602,16 @@ module Aidp
600
602
 
601
603
  # Get devcontainer configuration
602
604
  def devcontainer_config
603
- @config[:devcontainer] || default_devcontainer_config
605
+ return @devcontainer_config if defined?(@devcontainer_config)
606
+
607
+ raw_config = @config[:devcontainer] || @config["devcontainer"]
608
+ base = deep_dup(default_devcontainer_config)
609
+
610
+ @devcontainer_config = if raw_config.is_a?(Hash)
611
+ deep_merge_hashes(base, deep_symbolize_keys(raw_config))
612
+ else
613
+ base
614
+ end
604
615
  end
605
616
 
606
617
  # Check if devcontainer features are enabled
@@ -629,7 +640,10 @@ module Aidp
629
640
 
630
641
  # Get devcontainer permissions config
631
642
  def devcontainer_permissions
632
- devcontainer_config[:permissions] || {}
643
+ permissions = devcontainer_config[:permissions]
644
+ return {} unless permissions.is_a?(Hash)
645
+
646
+ permissions.transform_keys { |key| key.to_sym }
633
647
  end
634
648
 
635
649
  # Check if dangerous filesystem operations are allowed in devcontainer
@@ -639,7 +653,15 @@ module Aidp
639
653
 
640
654
  # Get list of providers that should skip permission checks in devcontainer
641
655
  def devcontainer_skip_permission_checks
642
- devcontainer_permissions[:skip_permission_checks] || []
656
+ permissions = devcontainer_config[:permissions]
657
+ list = nil
658
+
659
+ if permissions.is_a?(Hash)
660
+ list = permissions[:skip_permission_checks] || permissions["skip_permission_checks"]
661
+ end
662
+
663
+ list = default_skip_permission_checks if list.nil?
664
+ Array(list).map(&:to_s)
643
665
  end
644
666
 
645
667
  # Check if a specific provider should skip permission checks in devcontainer
@@ -843,7 +865,7 @@ module Aidp
843
865
  max_backoff_seconds: 1800,
844
866
  next: {
845
867
  success: :agentic,
846
- failure: :decide_whats_next,
868
+ failure: :diagnose_failures,
847
869
  else: :decide_whats_next
848
870
  }
849
871
  },
@@ -856,7 +878,7 @@ module Aidp
856
878
  max_backoff_seconds: 1800,
857
879
  next: {
858
880
  success: :agentic,
859
- failure: :decide_whats_next,
881
+ failure: :diagnose_failures,
860
882
  else: :decide_whats_next
861
883
  }
862
884
  },
@@ -1055,7 +1077,7 @@ module Aidp
1055
1077
  force_detection: nil,
1056
1078
  permissions: {
1057
1079
  dangerous_filesystem_ops: false,
1058
- skip_permission_checks: []
1080
+ skip_permission_checks: ["claude"]
1059
1081
  },
1060
1082
  settings: {
1061
1083
  timeout_multiplier: 1.0,
@@ -1065,6 +1087,47 @@ module Aidp
1065
1087
  }
1066
1088
  end
1067
1089
 
1090
+ def default_skip_permission_checks
1091
+ Array(default_devcontainer_config.dig(:permissions, :skip_permission_checks)).map(&:to_s)
1092
+ end
1093
+
1094
+ def deep_symbolize_keys(value)
1095
+ case value
1096
+ when Hash
1097
+ value.each_with_object({}) do |(key, val), memo|
1098
+ memo[key.to_sym] = deep_symbolize_keys(val)
1099
+ end
1100
+ when Array
1101
+ value.map { |item| deep_symbolize_keys(item) }
1102
+ else
1103
+ value
1104
+ end
1105
+ end
1106
+
1107
+ def deep_merge_hashes(base, overrides)
1108
+ overrides.each do |key, value|
1109
+ base[key] = if base[key].is_a?(Hash) && value.is_a?(Hash)
1110
+ deep_merge_hashes(base[key], value)
1111
+ else
1112
+ value
1113
+ end
1114
+ end
1115
+ base
1116
+ end
1117
+
1118
+ def deep_dup(value)
1119
+ case value
1120
+ when Hash
1121
+ value.each_with_object({}) do |(key, val), memo|
1122
+ memo[key] = deep_dup(val)
1123
+ end
1124
+ when Array
1125
+ value.map { |item| deep_dup(item) }
1126
+ else
1127
+ value
1128
+ end
1129
+ end
1130
+
1068
1131
  # Custom error class for configuration issues
1069
1132
  class ConfigurationError < StandardError; end
1070
1133
  end
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require_relative "../debug_mixin"
5
5
  require_relative "../concurrency"
6
+ require_relative "../providers/error_taxonomy"
6
7
 
7
8
  module Aidp
8
9
  module Harness
@@ -252,9 +253,10 @@ module Aidp
252
253
  # Check if we should retry based on error type and strategy
253
254
  def should_retry?(error_info, strategy)
254
255
  return false unless strategy[:enabled]
255
- return false if error_info[:error_type] == :rate_limit
256
- return false if error_info[:error_type] == :authentication
257
- return false if error_info[:error_type] == :permission_denied
256
+
257
+ # Use ErrorTaxonomy to determine if error is retryable
258
+ error_type = error_info[:error_type]
259
+ return false unless Aidp::Providers::ErrorTaxonomy.retryable?(error_type)
258
260
 
259
261
  # Check circuit breaker
260
262
  circuit_breaker_key = "#{error_info[:provider]}:#{error_info[:model]}"
@@ -337,7 +339,62 @@ module Aidp
337
339
 
338
340
  def initialize_retry_strategies
339
341
  @retry_strategies = {
340
- # Network errors - retry with exponential backoff
342
+ # Transient errors - retry with exponential backoff
343
+ transient: {
344
+ name: "transient",
345
+ enabled: true,
346
+ max_retries: 3,
347
+ backoff_strategy: :exponential,
348
+ base_delay: 1.0,
349
+ max_delay: 30.0,
350
+ jitter: true
351
+ },
352
+
353
+ # Rate limited errors - no retry, immediate switch
354
+ rate_limited: {
355
+ name: "rate_limited",
356
+ enabled: false,
357
+ max_retries: 0,
358
+ backoff_strategy: :none,
359
+ base_delay: 0.0,
360
+ max_delay: 0.0,
361
+ jitter: false
362
+ },
363
+
364
+ # Authentication expired - no retry, switch provider
365
+ auth_expired: {
366
+ name: "auth_expired",
367
+ enabled: false,
368
+ max_retries: 0,
369
+ backoff_strategy: :none,
370
+ base_delay: 0.0,
371
+ max_delay: 0.0,
372
+ jitter: false
373
+ },
374
+
375
+ # Quota exceeded - no retry, switch provider
376
+ quota_exceeded: {
377
+ name: "quota_exceeded",
378
+ enabled: false,
379
+ max_retries: 0,
380
+ backoff_strategy: :none,
381
+ base_delay: 0.0,
382
+ max_delay: 0.0,
383
+ jitter: false
384
+ },
385
+
386
+ # Permanent errors - no retry, escalate
387
+ permanent: {
388
+ name: "permanent",
389
+ enabled: false,
390
+ max_retries: 0,
391
+ backoff_strategy: :none,
392
+ base_delay: 0.0,
393
+ max_delay: 0.0,
394
+ jitter: false
395
+ },
396
+
397
+ # Legacy aliases for backward compatibility
341
398
  network_error: {
342
399
  name: "network_error",
343
400
  enabled: true,
@@ -347,8 +404,6 @@ module Aidp
347
404
  max_delay: 30.0,
348
405
  jitter: true
349
406
  },
350
-
351
- # Server errors - retry with linear backoff
352
407
  server_error: {
353
408
  name: "server_error",
354
409
  enabled: true,
@@ -358,8 +413,6 @@ module Aidp
358
413
  max_delay: 10.0,
359
414
  jitter: true
360
415
  },
361
-
362
- # Timeout errors - retry with exponential backoff
363
416
  timeout: {
364
417
  name: "timeout",
365
418
  enabled: true,
@@ -369,8 +422,6 @@ module Aidp
369
422
  max_delay: 15.0,
370
423
  jitter: true
371
424
  },
372
-
373
- # Rate limit errors - no retry, immediate switch
374
425
  rate_limit: {
375
426
  name: "rate_limit",
376
427
  enabled: false,
@@ -380,8 +431,6 @@ module Aidp
380
431
  max_delay: 0.0,
381
432
  jitter: false
382
433
  },
383
-
384
- # Authentication errors - no retry, escalate
385
434
  authentication: {
386
435
  name: "authentication",
387
436
  enabled: false,
@@ -391,8 +440,6 @@ module Aidp
391
440
  max_delay: 0.0,
392
441
  jitter: false
393
442
  },
394
-
395
- # Permission denied - no retry, escalate
396
443
  permission_denied: {
397
444
  name: "permission_denied",
398
445
  enabled: false,
@@ -586,41 +633,36 @@ module Aidp
586
633
  private
587
634
 
588
635
  def classify_error_type(error)
589
- return :unknown if error.nil?
636
+ return :transient if error.nil?
637
+
638
+ # Use standardized error taxonomy for classification
639
+ message = error.message.to_s
590
640
 
641
+ # First, use ErrorTaxonomy to classify by message
642
+ category = Aidp::Providers::ErrorTaxonomy.classify_message(message)
643
+
644
+ # Override with more specific classification based on error type
591
645
  case error
592
646
  when Timeout::Error
593
- :timeout
647
+ :transient
594
648
  when Net::HTTPError
595
649
  case error.response.code.to_i
596
650
  when 429
597
- :rate_limit
651
+ :rate_limited
598
652
  when 401, 403
599
- :authentication
653
+ :auth_expired
600
654
  when 500..599
601
- :server_error
655
+ :transient
656
+ when 400
657
+ :permanent
602
658
  else
603
- :network_error
659
+ :transient
604
660
  end
605
661
  when SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH
606
- :network_error
607
- when StandardError
608
- # Check error message for common patterns
609
- message = error.message.downcase
610
-
611
- if message.include?("rate limit") || message.include?("quota")
612
- :rate_limit
613
- elsif message.include?("timeout")
614
- :timeout
615
- elsif message.include?("auth") || message.include?("permission")
616
- :authentication
617
- elsif message.include?("server") || message.include?("internal")
618
- :server_error
619
- else
620
- :default
621
- end
662
+ :transient
622
663
  else
623
- :default
664
+ # Use message-based classification from ErrorTaxonomy
665
+ category
624
666
  end
625
667
  end
626
668
  end
@@ -629,22 +671,41 @@ module Aidp
629
671
  def create_recovery_plan(error_info, _context = {})
630
672
  error_type = error_info[:error_type]
631
673
 
674
+ # Use ErrorTaxonomy to determine recovery strategy
632
675
  case error_type
633
- when :rate_limit
676
+ when :rate_limited
634
677
  {
635
678
  action: :switch_provider,
636
679
  reason: "Rate limit reached, switching provider",
637
680
  priority: :high
638
681
  }
639
- when :authentication, :permission_denied
640
- # Previously we escalated immediately. Instead, attempt a provider switch
641
- # so workflows can continue with alternate providers (e.g., Gemini, Cursor)
642
- # while the user resolves credentials for the failing provider.
682
+ when :auth_expired
683
+ # Attempt a provider switch so workflows can continue with alternate providers
684
+ # while the user resolves credentials for the failing provider
643
685
  {
644
686
  action: :switch_provider,
645
- reason: "Authentication/permission issue – switching provider to continue",
687
+ reason: "Authentication expired – switching provider to continue",
646
688
  priority: :critical
647
689
  }
690
+ when :quota_exceeded
691
+ {
692
+ action: :switch_provider,
693
+ reason: "Quota exceeded, switching provider",
694
+ priority: :high
695
+ }
696
+ when :transient
697
+ {
698
+ action: :switch_model,
699
+ reason: "Transient error, trying alternate model",
700
+ priority: :medium
701
+ }
702
+ when :permanent
703
+ {
704
+ action: :escalate,
705
+ reason: "Permanent error, requires manual intervention",
706
+ priority: :critical
707
+ }
708
+ # Legacy error type mappings for backward compatibility
648
709
  when :timeout
649
710
  {
650
711
  action: :switch_model,
@@ -655,7 +716,7 @@ module Aidp
655
716
  {
656
717
  action: :switch_provider,
657
718
  reason: "Network error, switching provider",
658
- priority: :high
719
+ priority: :medium
659
720
  }
660
721
  when :server_error
661
722
  {
@@ -663,6 +724,18 @@ module Aidp
663
724
  reason: "Server error, switching provider",
664
725
  priority: :medium
665
726
  }
727
+ when :authentication, :permission_denied
728
+ {
729
+ action: :switch_provider,
730
+ reason: "Authentication/permission issue – switching provider to continue",
731
+ priority: :critical
732
+ }
733
+ when :rate_limit
734
+ {
735
+ action: :switch_provider,
736
+ reason: "Rate limit reached, switching provider",
737
+ priority: :high
738
+ }
666
739
  else
667
740
  {
668
741
  action: :switch_provider,
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "tty-prompt"
4
4
  require_relative "provider_factory"
5
+ require_relative "provider_metrics"
5
6
  require_relative "../rescue_logging"
6
7
  require_relative "../concurrency"
7
8
 
@@ -40,6 +41,20 @@ module Aidp
40
41
  @unavailable_cache = {}
41
42
  @binary_check_cache = {}
42
43
  @binary_check_ttl = 300 # seconds
44
+
45
+ # Initialize persistence
46
+ project_dir = if configuration.respond_to?(:project_dir)
47
+ configuration.project_dir
48
+ elsif configuration.respond_to?(:root_dir)
49
+ configuration.root_dir
50
+ else
51
+ Dir.pwd
52
+ end
53
+ @metrics_persistence = ProviderMetrics.new(project_dir)
54
+
55
+ # Load persisted metrics
56
+ load_persisted_metrics
57
+
43
58
  initialize_fallback_chains
44
59
  initialize_provider_health
45
60
  initialize_model_configs
@@ -932,6 +947,9 @@ module Aidp
932
947
  # Update provider health
933
948
  update_provider_health(provider_name, "rate_limited")
934
949
 
950
+ # Persist rate limit info to disk
951
+ save_persisted_rate_limits
952
+
935
953
  # Switch to next provider if current one is rate limited
936
954
  if provider_name == current_provider
937
955
  switch_provider("rate_limit", {provider: provider_name})
@@ -996,6 +1014,9 @@ module Aidp
996
1014
  metrics[:last_error_time] = Time.now
997
1015
  update_provider_health(provider_name, "error", {error: error})
998
1016
  end
1017
+
1018
+ # Persist metrics to disk
1019
+ save_persisted_metrics
999
1020
  end
1000
1021
 
1001
1022
  # Record model metrics
@@ -1611,6 +1632,49 @@ module Aidp
1611
1632
  # Most models reset rate limits every hour
1612
1633
  Time.now + (60 * 60)
1613
1634
  end
1635
+
1636
+ # Load persisted metrics from disk
1637
+ def load_persisted_metrics
1638
+ return unless @metrics_persistence
1639
+
1640
+ # Load provider metrics
1641
+ persisted_metrics = @metrics_persistence.load_metrics
1642
+ @provider_metrics.merge!(persisted_metrics) if persisted_metrics.is_a?(Hash)
1643
+
1644
+ # Load rate limit info
1645
+ persisted_rate_limits = @metrics_persistence.load_rate_limits
1646
+ @rate_limit_info.merge!(persisted_rate_limits) if persisted_rate_limits.is_a?(Hash)
1647
+
1648
+ # Clean up expired rate limits
1649
+ cleanup_expired_rate_limits
1650
+ rescue => e
1651
+ log_rescue(e, component: "provider_manager", action: "load_persisted_metrics", fallback: nil)
1652
+ end
1653
+
1654
+ # Save persisted metrics to disk
1655
+ def save_persisted_metrics
1656
+ return unless @metrics_persistence
1657
+ @metrics_persistence.save_metrics(@provider_metrics)
1658
+ rescue => e
1659
+ log_rescue(e, component: "provider_manager", action: "save_persisted_metrics", fallback: nil)
1660
+ end
1661
+
1662
+ # Save persisted rate limits to disk
1663
+ def save_persisted_rate_limits
1664
+ return unless @metrics_persistence
1665
+ @metrics_persistence.save_rate_limits(@rate_limit_info)
1666
+ rescue => e
1667
+ log_rescue(e, component: "provider_manager", action: "save_persisted_rate_limits", fallback: nil)
1668
+ end
1669
+
1670
+ # Clean up expired rate limits from memory
1671
+ def cleanup_expired_rate_limits
1672
+ now = Time.now
1673
+ @rate_limit_info.delete_if do |_provider, info|
1674
+ reset_time = info[:reset_time]
1675
+ reset_time && now >= reset_time
1676
+ end
1677
+ end
1614
1678
  end
1615
1679
  end
1616
1680
  end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require_relative "../rescue_logging"
6
+
7
+ module Aidp
8
+ module Harness
9
+ # Persists provider metrics and rate limit information to disk
10
+ # Enables the provider dashboard to display real-time state
11
+ class ProviderMetrics
12
+ include Aidp::RescueLogging
13
+
14
+ attr_reader :project_dir, :metrics_file, :rate_limit_file
15
+
16
+ def initialize(project_dir)
17
+ @project_dir = project_dir
18
+ @metrics_file = File.join(project_dir, ".aidp", "provider_metrics.yml")
19
+ @rate_limit_file = File.join(project_dir, ".aidp", "provider_rate_limits.yml")
20
+ ensure_directory
21
+ end
22
+
23
+ # Save provider metrics to disk
24
+ def save_metrics(metrics_hash)
25
+ return if metrics_hash.nil? || metrics_hash.empty?
26
+
27
+ # Convert Time objects to ISO8601 strings for YAML serialization
28
+ serializable_metrics = serialize_metrics(metrics_hash)
29
+
30
+ File.write(@metrics_file, YAML.dump(serializable_metrics))
31
+ rescue => e
32
+ log_rescue(e, component: "provider_metrics", action: "save_metrics", fallback: nil)
33
+ end
34
+
35
+ # Load provider metrics from disk
36
+ def load_metrics
37
+ return {} unless File.exist?(@metrics_file)
38
+
39
+ data = YAML.safe_load_file(@metrics_file, permitted_classes: [Time, Date, Symbol], aliases: true)
40
+ return {} unless data.is_a?(Hash)
41
+
42
+ # Convert ISO8601 strings back to Time objects
43
+ deserialize_metrics(data)
44
+ rescue => e
45
+ log_rescue(e, component: "provider_metrics", action: "load_metrics", fallback: {})
46
+ {}
47
+ end
48
+
49
+ # Save rate limit information to disk
50
+ def save_rate_limits(rate_limit_hash)
51
+ return if rate_limit_hash.nil? || rate_limit_hash.empty?
52
+
53
+ # Convert Time objects to ISO8601 strings for YAML serialization
54
+ serializable_rate_limits = serialize_rate_limits(rate_limit_hash)
55
+
56
+ File.write(@rate_limit_file, YAML.dump(serializable_rate_limits))
57
+ rescue => e
58
+ log_rescue(e, component: "provider_metrics", action: "save_rate_limits", fallback: nil)
59
+ end
60
+
61
+ # Load rate limit information from disk
62
+ def load_rate_limits
63
+ return {} unless File.exist?(@rate_limit_file)
64
+
65
+ data = YAML.safe_load_file(@rate_limit_file, permitted_classes: [Time, Date, Symbol], aliases: true)
66
+ return {} unless data.is_a?(Hash)
67
+
68
+ # Convert ISO8601 strings back to Time objects
69
+ deserialize_rate_limits(data)
70
+ rescue => e
71
+ log_rescue(e, component: "provider_metrics", action: "load_rate_limits", fallback: {})
72
+ {}
73
+ end
74
+
75
+ # Clear all persisted metrics
76
+ def clear
77
+ File.delete(@metrics_file) if File.exist?(@metrics_file)
78
+ File.delete(@rate_limit_file) if File.exist?(@rate_limit_file)
79
+ end
80
+
81
+ private
82
+
83
+ def ensure_directory
84
+ aidp_dir = File.join(@project_dir, ".aidp")
85
+ FileUtils.mkdir_p(aidp_dir) unless File.directory?(aidp_dir)
86
+ end
87
+
88
+ def serialize_metrics(metrics_hash)
89
+ metrics_hash.transform_values do |provider_metrics|
90
+ next provider_metrics unless provider_metrics.is_a?(Hash)
91
+
92
+ provider_metrics.transform_values do |value|
93
+ value.is_a?(Time) ? value.iso8601 : value
94
+ end
95
+ end
96
+ end
97
+
98
+ def deserialize_metrics(metrics_hash)
99
+ metrics_hash.transform_values do |provider_metrics|
100
+ next provider_metrics unless provider_metrics.is_a?(Hash)
101
+
102
+ provider_metrics.transform_keys(&:to_sym).transform_values do |value|
103
+ parse_time_if_string(value)
104
+ end
105
+ end
106
+ end
107
+
108
+ def serialize_rate_limits(rate_limit_hash)
109
+ rate_limit_hash.transform_values do |limit_info|
110
+ next limit_info unless limit_info.is_a?(Hash)
111
+
112
+ limit_info.transform_values do |value|
113
+ value.is_a?(Time) ? value.iso8601 : value
114
+ end
115
+ end
116
+ end
117
+
118
+ def deserialize_rate_limits(rate_limit_hash)
119
+ rate_limit_hash.transform_values do |limit_info|
120
+ next limit_info unless limit_info.is_a?(Hash)
121
+
122
+ limit_info.transform_keys(&:to_sym).transform_values do |value|
123
+ parse_time_if_string(value)
124
+ end
125
+ end
126
+ end
127
+
128
+ def parse_time_if_string(value)
129
+ return value unless value.is_a?(String)
130
+
131
+ # Try to parse ISO8601 timestamp
132
+ Time.parse(value)
133
+ rescue ArgumentError
134
+ value
135
+ end
136
+ end
137
+ end
138
+ end