aidp 0.34.1 → 0.39.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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -14
  3. data/lib/aidp/agent_harness_adapter.rb +309 -0
  4. data/lib/aidp/cli/issue_importer.rb +20 -6
  5. data/lib/aidp/cli/jobs_command.rb +1 -1
  6. data/lib/aidp/cli/models_command.rb +9 -16
  7. data/lib/aidp/cli/prompts_command.rb +253 -0
  8. data/lib/aidp/cli/security_command.rb +4 -10
  9. data/lib/aidp/cli/storage_command.rb +277 -0
  10. data/lib/aidp/cli/temporal_command.rb +460 -0
  11. data/lib/aidp/cli.rb +24 -2
  12. data/lib/aidp/config.rb +15 -0
  13. data/lib/aidp/config_paths.rb +12 -2
  14. data/lib/aidp/database/migrations.rb +114 -0
  15. data/lib/aidp/database/repositories/checkpoint_repository.rb +174 -0
  16. data/lib/aidp/database/repositories/deprecated_models_repository.rb +206 -0
  17. data/lib/aidp/database/repositories/evaluation_repository.rb +208 -0
  18. data/lib/aidp/database/repositories/harness_state_repository.rb +140 -0
  19. data/lib/aidp/database/repositories/job_repository.rb +278 -0
  20. data/lib/aidp/database/repositories/model_cache_repository.rb +194 -0
  21. data/lib/aidp/database/repositories/progress_repository.rb +210 -0
  22. data/lib/aidp/database/repositories/prompt_archive_repository.rb +195 -0
  23. data/lib/aidp/database/repositories/prompt_feedback_repository.rb +266 -0
  24. data/lib/aidp/database/repositories/provider_info_cache_repository.rb +208 -0
  25. data/lib/aidp/database/repositories/provider_metrics_repository.rb +245 -0
  26. data/lib/aidp/database/repositories/secrets_repository.rb +187 -0
  27. data/lib/aidp/database/repositories/task_repository.rb +231 -0
  28. data/lib/aidp/database/repositories/template_version_repository.rb +383 -0
  29. data/lib/aidp/database/repositories/watch_state_repository.rb +472 -0
  30. data/lib/aidp/database/repositories/workstream_repository.rb +310 -0
  31. data/lib/aidp/database/repositories/worktree_repository.rb +240 -0
  32. data/lib/aidp/database/repository.rb +166 -0
  33. data/lib/aidp/database/schema.rb +347 -0
  34. data/lib/aidp/database/storage_migrator.rb +693 -0
  35. data/lib/aidp/database.rb +139 -0
  36. data/lib/aidp/debug_mixin.rb +2 -2
  37. data/lib/aidp/execute/checkpoint.rb +26 -0
  38. data/lib/aidp/execute/checkpoint_display.rb +16 -0
  39. data/lib/aidp/execute/prompt_evaluator.rb +30 -57
  40. data/lib/aidp/execute/work_loop_runner.rb +294 -46
  41. data/lib/aidp/firewall/provider_requirements_collector.rb +7 -23
  42. data/lib/aidp/harness/agent_harness_provider_manager.rb +275 -0
  43. data/lib/aidp/harness/ai_decision_engine.rb +78 -296
  44. data/lib/aidp/harness/config_schema.rb +59 -0
  45. data/lib/aidp/harness/configuration.rb +229 -10
  46. data/lib/aidp/harness/deprecation_cache.rb +53 -11
  47. data/lib/aidp/harness/enhanced_runner.rb +1 -1
  48. data/lib/aidp/harness/error_handler.rb +11 -5
  49. data/lib/aidp/harness/provider_config.rb +36 -0
  50. data/lib/aidp/harness/provider_factory.rb +26 -24
  51. data/lib/aidp/harness/provider_info.rb +9 -5
  52. data/lib/aidp/harness/provider_manager.rb +2 -2
  53. data/lib/aidp/harness/provider_metrics.rb +103 -1
  54. data/lib/aidp/harness/rspec_command_optimizer.rb +166 -0
  55. data/lib/aidp/harness/ruby_llm_registry.rb +97 -8
  56. data/lib/aidp/harness/runner.rb +7 -2
  57. data/lib/aidp/harness/state_manager.rb +1 -1
  58. data/lib/aidp/harness/test_runner.rb +167 -11
  59. data/lib/aidp/harness/thinking_depth_manager.rb +564 -5
  60. data/lib/aidp/harness/usage_limit.rb +179 -0
  61. data/lib/aidp/harness/usage_limit_enforcer.rb +240 -0
  62. data/lib/aidp/harness/usage_limit_tracker.rb +308 -0
  63. data/lib/aidp/harness/usage_period.rb +234 -0
  64. data/lib/aidp/init/agent_instructions_generator.rb +317 -0
  65. data/lib/aidp/init/runner.rb +25 -4
  66. data/lib/aidp/interfaces/activity_monitor_interface.rb +234 -0
  67. data/lib/aidp/interfaces/binary_checker_interface.rb +232 -0
  68. data/lib/aidp/interfaces/command_executor_interface.rb +216 -0
  69. data/lib/aidp/interfaces/logger_interface.rb +150 -0
  70. data/lib/aidp/interfaces/ui_interface.rb +254 -0
  71. data/lib/aidp/logger.rb +25 -7
  72. data/lib/aidp/orchestration_adapter.rb +279 -0
  73. data/lib/aidp/pr_worktree_manager.rb +4 -15
  74. data/lib/aidp/prompts/feedback_collector.rb +198 -0
  75. data/lib/aidp/prompts/prompt_template_manager.rb +415 -0
  76. data/lib/aidp/prompts/template_evolver.rb +305 -0
  77. data/lib/aidp/prompts/template_version_manager.rb +325 -0
  78. data/lib/aidp/setup/in_memory_config_adapter.rb +257 -0
  79. data/lib/aidp/setup/in_memory_config_manager.rb +262 -0
  80. data/lib/aidp/setup/wizard.rb +616 -176
  81. data/lib/aidp/shell_executor.rb +58 -0
  82. data/lib/aidp/style_guide/selector.rb +56 -1
  83. data/lib/aidp/temporal/activities/analyze_issue_activity.rb +198 -0
  84. data/lib/aidp/temporal/activities/analyze_sub_task_activity.rb +163 -0
  85. data/lib/aidp/temporal/activities/base_activity.rb +78 -0
  86. data/lib/aidp/temporal/activities/create_plan_activity.rb +115 -0
  87. data/lib/aidp/temporal/activities/create_pr_activity.rb +193 -0
  88. data/lib/aidp/temporal/activities/create_prompt_activity.rb +118 -0
  89. data/lib/aidp/temporal/activities/diagnose_failure_activity.rb +228 -0
  90. data/lib/aidp/temporal/activities/prepare_next_iteration_activity.rb +147 -0
  91. data/lib/aidp/temporal/activities/record_checkpoint_activity.rb +81 -0
  92. data/lib/aidp/temporal/activities/run_agent_activity.rb +139 -0
  93. data/lib/aidp/temporal/activities/run_tests_activity.rb +114 -0
  94. data/lib/aidp/temporal/activities/run_work_loop_iteration_activity.rb +249 -0
  95. data/lib/aidp/temporal/configuration.rb +169 -0
  96. data/lib/aidp/temporal/connection.rb +93 -0
  97. data/lib/aidp/temporal/worker.rb +119 -0
  98. data/lib/aidp/temporal/workflow_client.rb +171 -0
  99. data/lib/aidp/temporal/workflows/base_workflow.rb +73 -0
  100. data/lib/aidp/temporal/workflows/issue_to_pr_workflow.rb +278 -0
  101. data/lib/aidp/temporal/workflows/sub_issue_workflow.rb +253 -0
  102. data/lib/aidp/temporal/workflows/work_loop_workflow.rb +321 -0
  103. data/lib/aidp/temporal.rb +120 -0
  104. data/lib/aidp/version.rb +1 -1
  105. data/lib/aidp/watch/base_processor.rb +35 -0
  106. data/lib/aidp/watch/build_processor.rb +132 -0
  107. data/lib/aidp/watch/github_state_extractor.rb +0 -8
  108. data/lib/aidp/watch/rebase_processor.rb +245 -0
  109. data/lib/aidp/watch/round_robin_scheduler.rb +150 -0
  110. data/lib/aidp/watch/runner.rb +366 -285
  111. data/lib/aidp/watch/state_store.rb +80 -0
  112. data/lib/aidp/watch/work_item.rb +96 -0
  113. data/lib/aidp/watch/worktree_cleanup_job.rb +226 -0
  114. data/lib/aidp/watch/worktree_reconciler.rb +643 -0
  115. data/lib/aidp/workflows/guided_agent.rb +1 -1
  116. data/lib/aidp/worktree.rb +62 -0
  117. data/templates/aidp.yml.example +70 -0
  118. metadata +117 -16
  119. data/lib/aidp/analyze/json_file_storage.rb +0 -292
  120. data/lib/aidp/execute/future_work_backlog.rb +0 -411
  121. data/lib/aidp/providers/adapter.rb +0 -239
  122. data/lib/aidp/providers/aider.rb +0 -264
  123. data/lib/aidp/providers/anthropic.rb +0 -614
  124. data/lib/aidp/providers/base.rb +0 -617
  125. data/lib/aidp/providers/capability_registry.rb +0 -204
  126. data/lib/aidp/providers/codex.rb +0 -267
  127. data/lib/aidp/providers/cursor.rb +0 -304
  128. data/lib/aidp/providers/error_taxonomy.rb +0 -195
  129. data/lib/aidp/providers/gemini.rb +0 -110
  130. data/lib/aidp/providers/github_copilot.rb +0 -227
  131. data/lib/aidp/providers/kilocode.rb +0 -157
  132. data/lib/aidp/providers/opencode.rb +0 -139
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require_relative "usage_period"
6
+ require_relative "../rescue_logging"
7
+
8
+ module Aidp
9
+ module Harness
10
+ # Domain service for tracking usage metrics per provider and period
11
+ # Handles recording usage, querying current usage, and period resets
12
+ class UsageLimitTracker
13
+ include Aidp::RescueLogging
14
+
15
+ # Maximum number of historical periods to retain for trend analysis
16
+ MAX_HISTORY_PERIODS = 12
17
+
18
+ attr_reader :provider_name, :project_dir
19
+
20
+ def initialize(provider_name:, project_dir:, metrics_repo: nil)
21
+ @provider_name = provider_name.to_s
22
+ @project_dir = project_dir
23
+ @metrics_repo = metrics_repo
24
+ @usage_file = File.join(project_dir, ".aidp", "usage_tracking", "#{@provider_name}.yml")
25
+ @usage_data = nil
26
+ @lock = Mutex.new if defined?(Mutex)
27
+
28
+ Aidp.log_debug("usage_limit_tracker", "initialized",
29
+ provider: @provider_name,
30
+ project_dir: @project_dir)
31
+ end
32
+
33
+ # Record usage for a request
34
+ #
35
+ # @param tokens [Integer] Number of tokens used
36
+ # @param cost [Float] Cost incurred
37
+ # @param tier [String] Tier used (mini, standard, advanced, etc.)
38
+ # @param period_type [String] Period type (daily, weekly, monthly)
39
+ # @param reset_day [Integer] Reset day for monthly periods
40
+ def record_usage(tokens:, cost:, tier: "standard", period_type: "monthly", reset_day: 1)
41
+ Aidp.log_debug("usage_limit_tracker", "recording_usage",
42
+ provider: @provider_name,
43
+ tokens: tokens,
44
+ cost: cost,
45
+ tier: tier)
46
+
47
+ return unless tokens.positive? || cost.positive?
48
+
49
+ with_lock do
50
+ data = load_usage_data
51
+ period = UsagePeriod.current(period_type: period_type, reset_day: reset_day)
52
+ period_key = period.period_key
53
+
54
+ # Initialize period data if needed
55
+ data[:periods] ||= {}
56
+ period_sym = period_key.to_sym
57
+ data[:periods][period_sym] ||= new_period_data(period)
58
+
59
+ # Update usage metrics
60
+ period_data = data[:periods][period_sym]
61
+ period_data[:total_tokens] = (period_data[:total_tokens] || 0) + tokens
62
+ period_data[:total_cost] = (period_data[:total_cost] || 0.0) + cost
63
+ period_data[:request_count] = (period_data[:request_count] || 0) + 1
64
+ period_data[:last_updated] = Time.now.iso8601
65
+
66
+ # Update tier-specific usage
67
+ tier_key = tier.to_s.downcase
68
+ period_data[:tier_usage] ||= {}
69
+ period_data[:tier_usage][tier_key] ||= {tokens: 0, cost: 0.0, requests: 0}
70
+ period_data[:tier_usage][tier_key][:tokens] += tokens
71
+ period_data[:tier_usage][tier_key][:cost] += cost
72
+ period_data[:tier_usage][tier_key][:requests] += 1
73
+
74
+ # Prune old periods
75
+ prune_old_periods(data)
76
+
77
+ # Save updated data
78
+ save_usage_data(data)
79
+ @usage_data = data
80
+ end
81
+ end
82
+
83
+ # Get current usage for a period
84
+ #
85
+ # @param period_type [String] Period type (daily, weekly, monthly)
86
+ # @param reset_day [Integer] Reset day for monthly periods
87
+ # @return [Hash] Current usage data with keys:
88
+ # - :period_key [String] Period identifier (e.g., "2024-01")
89
+ # - :period_description [String] Human-readable period description
90
+ # - :total_tokens [Integer] Total tokens used in period
91
+ # - :total_cost [Float] Total cost incurred in period
92
+ # - :request_count [Integer] Number of requests made
93
+ # - :tier_usage [Hash] Usage breakdown by tier
94
+ # - :start_time [Time] Period start time
95
+ # - :end_time [Time] Period end time
96
+ # - :remaining_seconds [Integer] Seconds until period ends
97
+ def current_usage(period_type: "monthly", reset_day: 1)
98
+ period = UsagePeriod.current(period_type: period_type, reset_day: reset_day)
99
+ period_key = period.period_key
100
+
101
+ data = load_usage_data
102
+ period_data = data[:periods]&.[](period_key.to_sym)
103
+
104
+ if period_data
105
+ {
106
+ period_key: period_key,
107
+ period_description: period.description,
108
+ total_tokens: period_data[:total_tokens] || 0,
109
+ total_cost: period_data[:total_cost] || 0.0,
110
+ request_count: period_data[:request_count] || 0,
111
+ tier_usage: period_data[:tier_usage] || {},
112
+ start_time: period.start_time,
113
+ end_time: period.end_time,
114
+ remaining_seconds: period.remaining_seconds
115
+ }
116
+ else
117
+ {
118
+ period_key: period_key,
119
+ period_description: period.description,
120
+ total_tokens: 0,
121
+ total_cost: 0.0,
122
+ request_count: 0,
123
+ tier_usage: {},
124
+ start_time: period.start_time,
125
+ end_time: period.end_time,
126
+ remaining_seconds: period.remaining_seconds
127
+ }
128
+ end
129
+ end
130
+
131
+ # Get usage for a specific tier in the current period
132
+ #
133
+ # @param tier [String] Tier name
134
+ # @param period_type [String] Period type
135
+ # @param reset_day [Integer] Reset day
136
+ # @return [Hash] Tier-specific usage data
137
+ def tier_usage(tier:, period_type: "monthly", reset_day: 1)
138
+ usage = current_usage(period_type: period_type, reset_day: reset_day)
139
+ tier_key = tier.to_s.downcase
140
+
141
+ tier_data = usage[:tier_usage][tier_key] || usage[:tier_usage][tier_key.to_sym]
142
+
143
+ if tier_data
144
+ {
145
+ tokens: tier_data[:tokens] || tier_data["tokens"] || 0,
146
+ cost: tier_data[:cost] || tier_data["cost"] || 0.0,
147
+ requests: tier_data[:requests] || tier_data["requests"] || 0
148
+ }
149
+ else
150
+ {tokens: 0, cost: 0.0, requests: 0}
151
+ end
152
+ end
153
+
154
+ # Get usage history across multiple periods
155
+ #
156
+ # @param limit [Integer] Maximum number of periods to return
157
+ # @return [Array<Hash>] Array of period usage data, newest first
158
+ # @note Sorting relies on lexicographic ordering of period keys (e.g., "2024-01", "2024-W01")
159
+ # which produces correct chronological order for ISO-formatted date strings
160
+ def usage_history(limit: MAX_HISTORY_PERIODS)
161
+ data = load_usage_data
162
+ return [] unless data[:periods].is_a?(Hash)
163
+
164
+ data[:periods]
165
+ .sort_by { |key, _| key }
166
+ .reverse
167
+ .take(limit)
168
+ .map do |key, period_data|
169
+ {
170
+ period_key: key,
171
+ total_tokens: period_data[:total_tokens] || 0,
172
+ total_cost: period_data[:total_cost] || 0.0,
173
+ request_count: period_data[:request_count] || 0,
174
+ tier_usage: period_data[:tier_usage] || {}
175
+ }
176
+ end
177
+ end
178
+
179
+ # Reset usage for the current period (for testing or manual reset)
180
+ #
181
+ # @param period_type [String] Period type
182
+ # @param reset_day [Integer] Reset day
183
+ def reset_current_period(period_type: "monthly", reset_day: 1)
184
+ Aidp.log_warn("usage_limit_tracker", "resetting_current_period",
185
+ provider: @provider_name,
186
+ period_type: period_type)
187
+
188
+ with_lock do
189
+ period = UsagePeriod.current(period_type: period_type, reset_day: reset_day)
190
+ period_key = period.period_key
191
+
192
+ data = load_usage_data
193
+ data[:periods]&.delete(period_key.to_sym)
194
+
195
+ save_usage_data(data)
196
+ @usage_data = data
197
+ end
198
+ end
199
+
200
+ # Clear all usage data (for testing or user request)
201
+ def clear_all_usage
202
+ Aidp.log_warn("usage_limit_tracker", "clearing_all_usage",
203
+ provider: @provider_name)
204
+
205
+ with_lock do
206
+ File.delete(@usage_file) if File.exist?(@usage_file)
207
+ @usage_data = nil
208
+ end
209
+ end
210
+
211
+ private
212
+
213
+ def load_usage_data
214
+ return @usage_data if @usage_data
215
+
216
+ ensure_directory
217
+
218
+ if File.exist?(@usage_file)
219
+ raw_data = YAML.safe_load_file(@usage_file,
220
+ permitted_classes: [Time, Date, Symbol],
221
+ aliases: true)
222
+ @usage_data = symbolize_keys(raw_data) if raw_data.is_a?(Hash)
223
+ end
224
+
225
+ @usage_data ||= {provider: @provider_name, periods: {}}
226
+ rescue => e
227
+ log_rescue(e, component: "usage_limit_tracker", action: "load_usage_data", fallback: {})
228
+ @usage_data = {provider: @provider_name, periods: {}}
229
+ end
230
+
231
+ def save_usage_data(data)
232
+ ensure_directory
233
+
234
+ # Write atomically using temp file + rename
235
+ temp_file = "#{@usage_file}.tmp"
236
+ File.write(temp_file, YAML.dump(stringify_keys(data)))
237
+ File.rename(temp_file, @usage_file)
238
+ rescue => e
239
+ log_rescue(e, component: "usage_limit_tracker", action: "save_usage_data", fallback: nil)
240
+ File.delete(temp_file) if File.exist?(temp_file)
241
+ end
242
+
243
+ def ensure_directory
244
+ dir = File.dirname(@usage_file)
245
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
246
+ end
247
+
248
+ def new_period_data(period)
249
+ {
250
+ period_type: period.period_type,
251
+ start_time: period.start_time.iso8601,
252
+ end_time: period.end_time.iso8601,
253
+ total_tokens: 0,
254
+ total_cost: 0.0,
255
+ request_count: 0,
256
+ tier_usage: {},
257
+ created_at: Time.now.iso8601,
258
+ last_updated: Time.now.iso8601
259
+ }
260
+ end
261
+
262
+ def prune_old_periods(data)
263
+ return unless data[:periods].is_a?(Hash)
264
+ return if data[:periods].size <= MAX_HISTORY_PERIODS
265
+
266
+ # Keep only the most recent periods
267
+ sorted_keys = data[:periods].keys.sort.reverse
268
+ keys_to_remove = sorted_keys[MAX_HISTORY_PERIODS..]
269
+
270
+ keys_to_remove&.each do |key|
271
+ data[:periods].delete(key)
272
+ end
273
+ end
274
+
275
+ def with_lock(&block)
276
+ if @lock
277
+ @lock.synchronize(&block)
278
+ else
279
+ yield
280
+ end
281
+ end
282
+
283
+ def symbolize_keys(hash)
284
+ return hash unless hash.is_a?(Hash)
285
+
286
+ hash.transform_keys(&:to_sym).transform_values do |v|
287
+ case v
288
+ when Hash then symbolize_keys(v)
289
+ when Array then v.map { |i| i.is_a?(Hash) ? symbolize_keys(i) : i }
290
+ else v
291
+ end
292
+ end
293
+ end
294
+
295
+ def stringify_keys(hash)
296
+ return hash unless hash.is_a?(Hash)
297
+
298
+ hash.transform_keys(&:to_s).transform_values do |v|
299
+ case v
300
+ when Hash then stringify_keys(v)
301
+ when Array then v.map { |i| i.is_a?(Hash) ? stringify_keys(i) : i }
302
+ else v
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "date"
5
+
6
+ module Aidp
7
+ module Harness
8
+ # Immutable value object representing a usage tracking period
9
+ # Handles time period calculations, boundaries, and reset logic
10
+ class UsagePeriod
11
+ PERIOD_TYPES = %w[daily weekly monthly].freeze
12
+
13
+ attr_reader :period_type, :reset_day, :start_time, :end_time
14
+
15
+ # Create a UsagePeriod for the current time
16
+ #
17
+ # @param period_type [String] Type of period: daily, weekly, monthly
18
+ # @param reset_day [Integer] Day of month for reset (1-28 for monthly)
19
+ # @param reference_time [Time] Reference time (defaults to now)
20
+ # @return [UsagePeriod] Period instance
21
+ def self.current(period_type:, reset_day: 1, reference_time: Time.now)
22
+ new(
23
+ period_type: period_type,
24
+ reset_day: reset_day,
25
+ reference_time: reference_time
26
+ )
27
+ end
28
+
29
+ # Create a UsagePeriod from a stored period key
30
+ #
31
+ # @param period_key [String] Period key (e.g., "2024-01", "2024-W01", "2024-01-15")
32
+ # @param period_type [String] Type of period
33
+ # @return [UsagePeriod] Period instance
34
+ def self.from_key(period_key, period_type:)
35
+ reference_time = parse_period_key(period_key, period_type)
36
+ new(period_type: period_type, reference_time: reference_time)
37
+ end
38
+
39
+ def initialize(period_type:, reset_day: 1, reference_time: Time.now)
40
+ @period_type = validate_period_type(period_type)
41
+ @reset_day = validate_reset_day(reset_day)
42
+ @reference_time = reference_time
43
+
44
+ calculate_boundaries
45
+ freeze
46
+ end
47
+
48
+ # Generate a unique key for this period (for storage)
49
+ #
50
+ # @return [String] Period key suitable for use as hash key
51
+ def period_key
52
+ case @period_type
53
+ when "daily"
54
+ @start_time.strftime("%Y-%m-%d")
55
+ when "weekly"
56
+ @start_time.strftime("%Y-W%V")
57
+ when "monthly"
58
+ @start_time.strftime("%Y-%m")
59
+ end
60
+ end
61
+
62
+ # Check if a given time falls within this period
63
+ #
64
+ # @param time [Time] Time to check
65
+ # @return [Boolean] True if time is within period
66
+ def contains?(time)
67
+ time >= @start_time && time < @end_time
68
+ end
69
+
70
+ # Check if this period has ended
71
+ #
72
+ # @param current_time [Time] Current time (defaults to now)
73
+ # @return [Boolean] True if period has ended
74
+ def ended?(current_time = Time.now)
75
+ current_time >= @end_time
76
+ end
77
+
78
+ # Get the next period after this one
79
+ #
80
+ # @return [UsagePeriod] Next period instance
81
+ def next_period
82
+ self.class.new(
83
+ period_type: @period_type,
84
+ reset_day: @reset_day,
85
+ reference_time: @end_time + 1
86
+ )
87
+ end
88
+
89
+ # Get the previous period before this one
90
+ #
91
+ # @return [UsagePeriod] Previous period instance
92
+ def previous_period
93
+ self.class.new(
94
+ period_type: @period_type,
95
+ reset_day: @reset_day,
96
+ reference_time: @start_time - 1
97
+ )
98
+ end
99
+
100
+ # Calculate remaining time in this period
101
+ #
102
+ # @param current_time [Time] Current time (defaults to now)
103
+ # @return [Integer] Seconds remaining in period
104
+ def remaining_seconds(current_time = Time.now)
105
+ return 0 if ended?(current_time)
106
+
107
+ (@end_time - current_time).to_i
108
+ end
109
+
110
+ # Human-readable period description
111
+ #
112
+ # @return [String] Period description
113
+ def description
114
+ case @period_type
115
+ when "daily"
116
+ @start_time.strftime("%B %d, %Y")
117
+ when "weekly"
118
+ "Week of #{@start_time.strftime("%B %d, %Y")}"
119
+ when "monthly"
120
+ @start_time.strftime("%B %Y")
121
+ end
122
+ end
123
+
124
+ # Value equality
125
+ def ==(other)
126
+ return false unless other.is_a?(UsagePeriod)
127
+
128
+ period_type == other.period_type &&
129
+ reset_day == other.reset_day &&
130
+ start_time == other.start_time &&
131
+ end_time == other.end_time
132
+ end
133
+
134
+ alias_method :eql?, :==
135
+
136
+ def hash
137
+ [period_type, reset_day, start_time, end_time].hash
138
+ end
139
+
140
+ # Convert to hash for serialization
141
+ def to_h
142
+ {
143
+ period_type: @period_type,
144
+ reset_day: @reset_day,
145
+ start_time: @start_time.iso8601,
146
+ end_time: @end_time.iso8601,
147
+ period_key: period_key
148
+ }
149
+ end
150
+
151
+ def self.parse_period_key(period_key, period_type)
152
+ case period_type
153
+ when "daily"
154
+ Time.parse(period_key)
155
+ when "weekly"
156
+ # Parse ISO week format (e.g., "2024-W01")
157
+ year, week = period_key.split("-W")
158
+ Date.commercial(year.to_i, week.to_i, 1).to_time
159
+ when "monthly"
160
+ Time.parse("#{period_key}-01")
161
+ else
162
+ Time.now
163
+ end
164
+ rescue ArgumentError
165
+ Time.now
166
+ end
167
+
168
+ private_class_method :parse_period_key
169
+
170
+ private
171
+
172
+ def validate_period_type(period_type)
173
+ type_str = period_type.to_s.downcase
174
+ PERIOD_TYPES.include?(type_str) ? type_str : "monthly"
175
+ end
176
+
177
+ def validate_reset_day(day)
178
+ day_int = day.to_i
179
+ day_int.between?(1, 28) ? day_int : 1
180
+ end
181
+
182
+ def calculate_boundaries
183
+ case @period_type
184
+ when "daily"
185
+ calculate_daily_boundaries
186
+ when "weekly"
187
+ calculate_weekly_boundaries
188
+ when "monthly"
189
+ calculate_monthly_boundaries
190
+ end
191
+ end
192
+
193
+ def calculate_daily_boundaries
194
+ @start_time = Time.new(
195
+ @reference_time.year,
196
+ @reference_time.month,
197
+ @reference_time.day,
198
+ 0, 0, 0
199
+ )
200
+ @end_time = @start_time + (24 * 60 * 60)
201
+ end
202
+
203
+ def calculate_weekly_boundaries
204
+ # Start on Monday (wday 1), end on Sunday
205
+ days_since_monday = (@reference_time.wday - 1) % 7
206
+ monday = @reference_time - (days_since_monday * 24 * 60 * 60)
207
+ @start_time = Time.new(monday.year, monday.month, monday.day, 0, 0, 0)
208
+ @end_time = @start_time + (7 * 24 * 60 * 60)
209
+ end
210
+
211
+ def calculate_monthly_boundaries
212
+ year = @reference_time.year
213
+ month = @reference_time.month
214
+ day = @reference_time.day
215
+
216
+ # Determine if we're before or after the reset day in current month
217
+ if day >= @reset_day
218
+ # Current period started this month
219
+ @start_time = Time.new(year, month, @reset_day, 0, 0, 0)
220
+ # End at reset day next month
221
+ next_month = (month == 12) ? 1 : month + 1
222
+ next_year = (month == 12) ? year + 1 : year
223
+ @end_time = Time.new(next_year, next_month, @reset_day, 0, 0, 0)
224
+ else
225
+ # Current period started last month
226
+ prev_month = (month == 1) ? 12 : month - 1
227
+ prev_year = (month == 1) ? year - 1 : year
228
+ @start_time = Time.new(prev_year, prev_month, @reset_day, 0, 0, 0)
229
+ @end_time = Time.new(year, month, @reset_day, 0, 0, 0)
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end