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.
- checksums.yaml +4 -4
- data/README.md +14 -14
- data/lib/aidp/agent_harness_adapter.rb +309 -0
- data/lib/aidp/cli/issue_importer.rb +20 -6
- data/lib/aidp/cli/jobs_command.rb +1 -1
- data/lib/aidp/cli/models_command.rb +9 -16
- data/lib/aidp/cli/prompts_command.rb +253 -0
- data/lib/aidp/cli/security_command.rb +4 -10
- data/lib/aidp/cli/storage_command.rb +277 -0
- data/lib/aidp/cli/temporal_command.rb +460 -0
- data/lib/aidp/cli.rb +24 -2
- data/lib/aidp/config.rb +15 -0
- data/lib/aidp/config_paths.rb +12 -2
- data/lib/aidp/database/migrations.rb +114 -0
- data/lib/aidp/database/repositories/checkpoint_repository.rb +174 -0
- data/lib/aidp/database/repositories/deprecated_models_repository.rb +206 -0
- data/lib/aidp/database/repositories/evaluation_repository.rb +208 -0
- data/lib/aidp/database/repositories/harness_state_repository.rb +140 -0
- data/lib/aidp/database/repositories/job_repository.rb +278 -0
- data/lib/aidp/database/repositories/model_cache_repository.rb +194 -0
- data/lib/aidp/database/repositories/progress_repository.rb +210 -0
- data/lib/aidp/database/repositories/prompt_archive_repository.rb +195 -0
- data/lib/aidp/database/repositories/prompt_feedback_repository.rb +266 -0
- data/lib/aidp/database/repositories/provider_info_cache_repository.rb +208 -0
- data/lib/aidp/database/repositories/provider_metrics_repository.rb +245 -0
- data/lib/aidp/database/repositories/secrets_repository.rb +187 -0
- data/lib/aidp/database/repositories/task_repository.rb +231 -0
- data/lib/aidp/database/repositories/template_version_repository.rb +383 -0
- data/lib/aidp/database/repositories/watch_state_repository.rb +472 -0
- data/lib/aidp/database/repositories/workstream_repository.rb +310 -0
- data/lib/aidp/database/repositories/worktree_repository.rb +240 -0
- data/lib/aidp/database/repository.rb +166 -0
- data/lib/aidp/database/schema.rb +347 -0
- data/lib/aidp/database/storage_migrator.rb +693 -0
- data/lib/aidp/database.rb +139 -0
- data/lib/aidp/debug_mixin.rb +2 -2
- data/lib/aidp/execute/checkpoint.rb +26 -0
- data/lib/aidp/execute/checkpoint_display.rb +16 -0
- data/lib/aidp/execute/prompt_evaluator.rb +30 -57
- data/lib/aidp/execute/work_loop_runner.rb +294 -46
- data/lib/aidp/firewall/provider_requirements_collector.rb +7 -23
- data/lib/aidp/harness/agent_harness_provider_manager.rb +275 -0
- data/lib/aidp/harness/ai_decision_engine.rb +78 -296
- data/lib/aidp/harness/config_schema.rb +59 -0
- data/lib/aidp/harness/configuration.rb +229 -10
- data/lib/aidp/harness/deprecation_cache.rb +53 -11
- data/lib/aidp/harness/enhanced_runner.rb +1 -1
- data/lib/aidp/harness/error_handler.rb +11 -5
- data/lib/aidp/harness/provider_config.rb +36 -0
- data/lib/aidp/harness/provider_factory.rb +26 -24
- data/lib/aidp/harness/provider_info.rb +9 -5
- data/lib/aidp/harness/provider_manager.rb +2 -2
- data/lib/aidp/harness/provider_metrics.rb +103 -1
- data/lib/aidp/harness/rspec_command_optimizer.rb +166 -0
- data/lib/aidp/harness/ruby_llm_registry.rb +97 -8
- data/lib/aidp/harness/runner.rb +7 -2
- data/lib/aidp/harness/state_manager.rb +1 -1
- data/lib/aidp/harness/test_runner.rb +167 -11
- data/lib/aidp/harness/thinking_depth_manager.rb +564 -5
- data/lib/aidp/harness/usage_limit.rb +179 -0
- data/lib/aidp/harness/usage_limit_enforcer.rb +240 -0
- data/lib/aidp/harness/usage_limit_tracker.rb +308 -0
- data/lib/aidp/harness/usage_period.rb +234 -0
- data/lib/aidp/init/agent_instructions_generator.rb +317 -0
- data/lib/aidp/init/runner.rb +25 -4
- data/lib/aidp/interfaces/activity_monitor_interface.rb +234 -0
- data/lib/aidp/interfaces/binary_checker_interface.rb +232 -0
- data/lib/aidp/interfaces/command_executor_interface.rb +216 -0
- data/lib/aidp/interfaces/logger_interface.rb +150 -0
- data/lib/aidp/interfaces/ui_interface.rb +254 -0
- data/lib/aidp/logger.rb +25 -7
- data/lib/aidp/orchestration_adapter.rb +279 -0
- data/lib/aidp/pr_worktree_manager.rb +4 -15
- data/lib/aidp/prompts/feedback_collector.rb +198 -0
- data/lib/aidp/prompts/prompt_template_manager.rb +415 -0
- data/lib/aidp/prompts/template_evolver.rb +305 -0
- data/lib/aidp/prompts/template_version_manager.rb +325 -0
- data/lib/aidp/setup/in_memory_config_adapter.rb +257 -0
- data/lib/aidp/setup/in_memory_config_manager.rb +262 -0
- data/lib/aidp/setup/wizard.rb +616 -176
- data/lib/aidp/shell_executor.rb +58 -0
- data/lib/aidp/style_guide/selector.rb +56 -1
- data/lib/aidp/temporal/activities/analyze_issue_activity.rb +198 -0
- data/lib/aidp/temporal/activities/analyze_sub_task_activity.rb +163 -0
- data/lib/aidp/temporal/activities/base_activity.rb +78 -0
- data/lib/aidp/temporal/activities/create_plan_activity.rb +115 -0
- data/lib/aidp/temporal/activities/create_pr_activity.rb +193 -0
- data/lib/aidp/temporal/activities/create_prompt_activity.rb +118 -0
- data/lib/aidp/temporal/activities/diagnose_failure_activity.rb +228 -0
- data/lib/aidp/temporal/activities/prepare_next_iteration_activity.rb +147 -0
- data/lib/aidp/temporal/activities/record_checkpoint_activity.rb +81 -0
- data/lib/aidp/temporal/activities/run_agent_activity.rb +139 -0
- data/lib/aidp/temporal/activities/run_tests_activity.rb +114 -0
- data/lib/aidp/temporal/activities/run_work_loop_iteration_activity.rb +249 -0
- data/lib/aidp/temporal/configuration.rb +169 -0
- data/lib/aidp/temporal/connection.rb +93 -0
- data/lib/aidp/temporal/worker.rb +119 -0
- data/lib/aidp/temporal/workflow_client.rb +171 -0
- data/lib/aidp/temporal/workflows/base_workflow.rb +73 -0
- data/lib/aidp/temporal/workflows/issue_to_pr_workflow.rb +278 -0
- data/lib/aidp/temporal/workflows/sub_issue_workflow.rb +253 -0
- data/lib/aidp/temporal/workflows/work_loop_workflow.rb +321 -0
- data/lib/aidp/temporal.rb +120 -0
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/base_processor.rb +35 -0
- data/lib/aidp/watch/build_processor.rb +132 -0
- data/lib/aidp/watch/github_state_extractor.rb +0 -8
- data/lib/aidp/watch/rebase_processor.rb +245 -0
- data/lib/aidp/watch/round_robin_scheduler.rb +150 -0
- data/lib/aidp/watch/runner.rb +366 -285
- data/lib/aidp/watch/state_store.rb +80 -0
- data/lib/aidp/watch/work_item.rb +96 -0
- data/lib/aidp/watch/worktree_cleanup_job.rb +226 -0
- data/lib/aidp/watch/worktree_reconciler.rb +643 -0
- data/lib/aidp/workflows/guided_agent.rb +1 -1
- data/lib/aidp/worktree.rb +62 -0
- data/templates/aidp.yml.example +70 -0
- metadata +117 -16
- data/lib/aidp/analyze/json_file_storage.rb +0 -292
- data/lib/aidp/execute/future_work_backlog.rb +0 -411
- data/lib/aidp/providers/adapter.rb +0 -239
- data/lib/aidp/providers/aider.rb +0 -264
- data/lib/aidp/providers/anthropic.rb +0 -614
- data/lib/aidp/providers/base.rb +0 -617
- data/lib/aidp/providers/capability_registry.rb +0 -204
- data/lib/aidp/providers/codex.rb +0 -267
- data/lib/aidp/providers/cursor.rb +0 -304
- data/lib/aidp/providers/error_taxonomy.rb +0 -195
- data/lib/aidp/providers/gemini.rb +0 -110
- data/lib/aidp/providers/github_copilot.rb +0 -227
- data/lib/aidp/providers/kilocode.rb +0 -157
- 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
|