aidp 0.21.1 → 0.22.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/lib/aidp/cli/devcontainer_commands.rb +501 -0
- data/lib/aidp/cli/issue_importer.rb +15 -3
- data/lib/aidp/cli.rb +91 -0
- data/lib/aidp/execute/prompt_manager.rb +16 -2
- data/lib/aidp/execute/runner.rb +10 -5
- data/lib/aidp/execute/work_loop_runner.rb +3 -3
- data/lib/aidp/harness/state/persistence.rb +12 -1
- data/lib/aidp/harness/state_manager.rb +13 -1
- data/lib/aidp/jobs/background_runner.rb +3 -1
- data/lib/aidp/logger.rb +41 -5
- data/lib/aidp/safe_directory.rb +87 -0
- data/lib/aidp/setup/devcontainer/backup_manager.rb +175 -0
- data/lib/aidp/setup/devcontainer/generator.rb +409 -0
- data/lib/aidp/setup/devcontainer/parser.rb +249 -0
- data/lib/aidp/setup/devcontainer/port_manager.rb +286 -0
- data/lib/aidp/setup/wizard.rb +145 -0
- data/lib/aidp/storage/csv_storage.rb +39 -2
- data/lib/aidp/storage/file_manager.rb +28 -3
- data/lib/aidp/storage/json_storage.rb +41 -2
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/workflows/guided_agent.rb +8 -42
- metadata +7 -1
|
@@ -19,12 +19,16 @@ module Aidp
|
|
|
19
19
|
|
|
20
20
|
def initialize(project_dir, config: nil)
|
|
21
21
|
@project_dir = project_dir
|
|
22
|
-
@
|
|
22
|
+
@aidp_dir = File.join(project_dir, ".aidp")
|
|
23
|
+
@prompt_path = File.join(@aidp_dir, PROMPT_FILENAME)
|
|
23
24
|
@archive_dir = File.join(project_dir, ARCHIVE_DIR)
|
|
24
25
|
@config = config
|
|
25
26
|
@optimizer = nil
|
|
26
27
|
@last_optimization_stats = nil
|
|
27
28
|
|
|
29
|
+
# Ensure .aidp directory exists
|
|
30
|
+
FileUtils.mkdir_p(@aidp_dir)
|
|
31
|
+
|
|
28
32
|
# Initialize optimizer if enabled
|
|
29
33
|
if config&.respond_to?(:prompt_optimization_enabled?) && config.prompt_optimization_enabled?
|
|
30
34
|
@optimizer = Aidp::PromptOptimization::Optimizer.new(
|
|
@@ -37,8 +41,15 @@ module Aidp
|
|
|
37
41
|
# Write content to PROMPT.md
|
|
38
42
|
# If optimization is enabled, stores the content but doesn't write yet
|
|
39
43
|
# (use write_optimized instead)
|
|
40
|
-
|
|
44
|
+
#
|
|
45
|
+
# @param content [String] The prompt content to write
|
|
46
|
+
# @param step_name [String, nil] Optional step name for immediate archiving
|
|
47
|
+
# @return [String, nil] Archive path if archived, nil otherwise
|
|
48
|
+
def write(content, step_name: nil)
|
|
41
49
|
File.write(@prompt_path, content)
|
|
50
|
+
|
|
51
|
+
# Archive immediately if step_name provided (issue #224)
|
|
52
|
+
archive(step_name) if step_name
|
|
42
53
|
end
|
|
43
54
|
|
|
44
55
|
# Write optimized prompt using intelligent fragment selection
|
|
@@ -88,6 +99,9 @@ module Aidp
|
|
|
88
99
|
budget_utilization: result.composition_result.budget_utilization
|
|
89
100
|
)
|
|
90
101
|
|
|
102
|
+
# Archive immediately if step_name provided (issue #224)
|
|
103
|
+
archive(task_context[:step_name]) if task_context[:step_name]
|
|
104
|
+
|
|
91
105
|
true
|
|
92
106
|
rescue => e
|
|
93
107
|
Aidp.logger.error("prompt_manager", "Optimization failed, using fallback", error: e.message)
|
data/lib/aidp/execute/runner.rb
CHANGED
|
@@ -308,12 +308,17 @@ module Aidp
|
|
|
308
308
|
end
|
|
309
309
|
|
|
310
310
|
def template_search_paths
|
|
311
|
+
gem_root = File.expand_path("../../../..", __dir__)
|
|
312
|
+
# We search:
|
|
313
|
+
# 1. Project root templates (supports entries like "planning/create_prd.md" since name carries subfolder)
|
|
314
|
+
# 2. Project COMMON templates (supports direct filename lookups like "00_PRD.md")
|
|
315
|
+
# 3. Gem root templates (fallback when project lacks local copies)
|
|
316
|
+
# 4. Gem COMMON templates (fallback for filename-only lookups)
|
|
311
317
|
[
|
|
312
|
-
File.join(@project_dir, "templates"),
|
|
313
|
-
File.join(@project_dir, "templates", "
|
|
314
|
-
File.join(
|
|
315
|
-
File.join(
|
|
316
|
-
File.join(@project_dir, "templates", "COMMON")
|
|
318
|
+
File.join(@project_dir, "templates"),
|
|
319
|
+
File.join(@project_dir, "templates", "COMMON"),
|
|
320
|
+
File.join(gem_root, "templates"),
|
|
321
|
+
File.join(gem_root, "templates", "COMMON")
|
|
317
322
|
]
|
|
318
323
|
end
|
|
319
324
|
|
|
@@ -375,7 +375,7 @@ module Aidp
|
|
|
375
375
|
previous_agent_summary: previous_summary
|
|
376
376
|
)
|
|
377
377
|
|
|
378
|
-
@prompt_manager.write(initial_prompt)
|
|
378
|
+
@prompt_manager.write(initial_prompt, step_name: @step_name)
|
|
379
379
|
display_message(" Created PROMPT.md (#{initial_prompt.length} chars)", type: :info)
|
|
380
380
|
end
|
|
381
381
|
|
|
@@ -603,10 +603,10 @@ module Aidp
|
|
|
603
603
|
|
|
604
604
|
return if test_results[:success] && lint_results[:success]
|
|
605
605
|
|
|
606
|
-
# Append failures to PROMPT.md
|
|
606
|
+
# Append failures to PROMPT.md and archive immediately (issue #224)
|
|
607
607
|
current_prompt = @prompt_manager.read
|
|
608
608
|
updated_prompt = current_prompt + "\n\n---\n\n" + failures.join("\n")
|
|
609
|
-
@prompt_manager.write(updated_prompt)
|
|
609
|
+
@prompt_manager.write(updated_prompt, step_name: @step_name)
|
|
610
610
|
|
|
611
611
|
display_message(" [NEXT_PATCH] Added failure reports and diagnostic to PROMPT.md", type: :warning)
|
|
612
612
|
end
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require_relative "../../safe_directory"
|
|
5
6
|
|
|
6
7
|
module Aidp
|
|
7
8
|
module Harness
|
|
8
9
|
module State
|
|
9
10
|
# Handles file I/O and persistence for state management
|
|
10
11
|
class Persistence
|
|
12
|
+
include Aidp::SafeDirectory
|
|
13
|
+
|
|
11
14
|
def initialize(project_dir, mode, skip_persistence: false)
|
|
12
15
|
@project_dir = project_dir
|
|
13
16
|
@mode = mode
|
|
@@ -83,7 +86,15 @@ module Aidp
|
|
|
83
86
|
end
|
|
84
87
|
|
|
85
88
|
def ensure_state_directory
|
|
86
|
-
|
|
89
|
+
return if @skip_persistence # Don't create directories when persistence is disabled
|
|
90
|
+
|
|
91
|
+
original_dir = @state_dir
|
|
92
|
+
@state_dir = safe_mkdir_p(@state_dir, component_name: "State::Persistence")
|
|
93
|
+
|
|
94
|
+
# If fallback occurred, switch to in-memory mode
|
|
95
|
+
if @state_dir != original_dir
|
|
96
|
+
@skip_persistence = true
|
|
97
|
+
end
|
|
87
98
|
end
|
|
88
99
|
|
|
89
100
|
def with_lock(&block)
|
|
@@ -6,11 +6,14 @@ require_relative "../execute/progress"
|
|
|
6
6
|
require_relative "../analyze/progress"
|
|
7
7
|
require_relative "../execute/steps"
|
|
8
8
|
require_relative "../analyze/steps"
|
|
9
|
+
require_relative "../safe_directory"
|
|
9
10
|
|
|
10
11
|
module Aidp
|
|
11
12
|
module Harness
|
|
12
13
|
# Manages harness-specific state and persistence, extending existing progress tracking
|
|
13
14
|
class StateManager
|
|
15
|
+
include Aidp::SafeDirectory
|
|
16
|
+
|
|
14
17
|
def initialize(project_dir, mode, skip_persistence: false)
|
|
15
18
|
@project_dir = project_dir
|
|
16
19
|
@mode = mode
|
|
@@ -576,7 +579,16 @@ module Aidp
|
|
|
576
579
|
end
|
|
577
580
|
|
|
578
581
|
def ensure_state_directory
|
|
579
|
-
|
|
582
|
+
return if @skip_persistence # Don't create directories when persistence is disabled
|
|
583
|
+
|
|
584
|
+
original_dir = @state_dir
|
|
585
|
+
@state_dir = safe_mkdir_p(@state_dir, component_name: "StateManager")
|
|
586
|
+
|
|
587
|
+
# If fallback occurred, switch to in-memory mode
|
|
588
|
+
if @state_dir != original_dir
|
|
589
|
+
@skip_persistence = true
|
|
590
|
+
@memory_state ||= {}
|
|
591
|
+
end
|
|
580
592
|
end
|
|
581
593
|
|
|
582
594
|
def with_lock(&_block)
|
|
@@ -6,6 +6,7 @@ require "fileutils"
|
|
|
6
6
|
require "time"
|
|
7
7
|
require_relative "../rescue_logging"
|
|
8
8
|
require_relative "../concurrency"
|
|
9
|
+
require_relative "../safe_directory"
|
|
9
10
|
|
|
10
11
|
module Aidp
|
|
11
12
|
module Jobs
|
|
@@ -14,6 +15,7 @@ module Aidp
|
|
|
14
15
|
class BackgroundRunner
|
|
15
16
|
include Aidp::MessageDisplay
|
|
16
17
|
include Aidp::RescueLogging
|
|
18
|
+
include Aidp::SafeDirectory
|
|
17
19
|
|
|
18
20
|
attr_reader :project_dir, :jobs_dir
|
|
19
21
|
|
|
@@ -190,7 +192,7 @@ module Aidp
|
|
|
190
192
|
private
|
|
191
193
|
|
|
192
194
|
def ensure_jobs_directory
|
|
193
|
-
|
|
195
|
+
@jobs_dir = safe_mkdir_p(@jobs_dir, component_name: "BackgroundRunner")
|
|
194
196
|
end
|
|
195
197
|
|
|
196
198
|
def generate_job_id
|
data/lib/aidp/logger.rb
CHANGED
|
@@ -39,6 +39,7 @@ module Aidp
|
|
|
39
39
|
@json_format = config[:json] || false
|
|
40
40
|
@max_size = config[:max_size_mb] ? config[:max_size_mb] * 1024 * 1024 : DEFAULT_MAX_SIZE
|
|
41
41
|
@max_files = config[:max_backups] || DEFAULT_MAX_FILES
|
|
42
|
+
@instrument_internal = config.key?(:instrument) ? config[:instrument] : (ENV["AIDP_LOG_INSTRUMENT"] == "1")
|
|
42
43
|
|
|
43
44
|
ensure_log_directory
|
|
44
45
|
setup_logger
|
|
@@ -95,12 +96,24 @@ module Aidp
|
|
|
95
96
|
|
|
96
97
|
def ensure_log_directory
|
|
97
98
|
log_dir = File.join(@project_dir, LOG_DIR)
|
|
98
|
-
|
|
99
|
+
return if Dir.exist?(log_dir)
|
|
100
|
+
begin
|
|
101
|
+
FileUtils.mkdir_p(log_dir)
|
|
102
|
+
rescue SystemCallError => e
|
|
103
|
+
Kernel.warn "[AIDP Logger] Cannot create log directory #{log_dir}: #{e.class}: #{e.message}. Falling back to STDERR logging."
|
|
104
|
+
# We intentionally do not re-raise; file logger setup will attempt and then fall back itself.
|
|
105
|
+
end
|
|
99
106
|
end
|
|
100
107
|
|
|
101
108
|
def setup_logger
|
|
102
109
|
info_path = File.join(@project_dir, INFO_LOG)
|
|
103
110
|
@logger = create_logger(info_path)
|
|
111
|
+
# Emit instrumentation after logger is available (avoid recursive Aidp.log_* calls during bootstrap)
|
|
112
|
+
return unless @instrument_internal
|
|
113
|
+
if defined?(@root_fallback) && @root_fallback
|
|
114
|
+
debug("logger", "root_fallback_applied", effective_dir: @root_fallback)
|
|
115
|
+
end
|
|
116
|
+
debug("logger", "logger_initialized", path: info_path, project_dir: @project_dir)
|
|
104
117
|
end
|
|
105
118
|
|
|
106
119
|
def create_logger(path)
|
|
@@ -200,12 +213,35 @@ module Aidp
|
|
|
200
213
|
# that would create odd top-level directories like "<STDERR>".
|
|
201
214
|
def sanitize_project_dir(dir)
|
|
202
215
|
return Dir.pwd if dir.nil?
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
216
|
+
raw_input = dir.to_s
|
|
217
|
+
raw_invalid = raw_input.empty? || raw_input.match?(/[<>|]/) || raw_input.match?(/[\x00-\x1F]/)
|
|
218
|
+
if raw_invalid
|
|
219
|
+
Kernel.warn "[AIDP Logger] Invalid project_dir '#{raw_input}' - falling back to #{Dir.pwd}"
|
|
220
|
+
if Dir.pwd == File::SEPARATOR
|
|
221
|
+
fallback = begin
|
|
222
|
+
home = Dir.home
|
|
223
|
+
(home && !home.empty?) ? home : Dir.tmpdir
|
|
224
|
+
rescue
|
|
225
|
+
Dir.tmpdir
|
|
226
|
+
end
|
|
227
|
+
@root_fallback = fallback
|
|
228
|
+
Kernel.warn "[AIDP Logger] Root directory detected - using #{fallback} for logging instead of '#{Dir.pwd}'"
|
|
229
|
+
return fallback
|
|
230
|
+
end
|
|
206
231
|
return Dir.pwd
|
|
207
232
|
end
|
|
208
|
-
|
|
233
|
+
if raw_input == File::SEPARATOR
|
|
234
|
+
fallback = begin
|
|
235
|
+
home = Dir.home
|
|
236
|
+
(home && !home.empty?) ? home : Dir.tmpdir
|
|
237
|
+
rescue
|
|
238
|
+
Dir.tmpdir
|
|
239
|
+
end
|
|
240
|
+
@root_fallback = fallback
|
|
241
|
+
Kernel.warn "[AIDP Logger] Root directory detected - using #{fallback} for logging instead of '#{raw_input}'"
|
|
242
|
+
return fallback
|
|
243
|
+
end
|
|
244
|
+
raw_input
|
|
209
245
|
end
|
|
210
246
|
end
|
|
211
247
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Aidp
|
|
7
|
+
# Provides safe directory creation with automatic fallback for permission errors
|
|
8
|
+
# Used across AIDP components to handle CI environments and restricted filesystems
|
|
9
|
+
module SafeDirectory
|
|
10
|
+
# Safely create a directory with fallback to temp/home on permission errors
|
|
11
|
+
#
|
|
12
|
+
# @param path [String] The directory path to create
|
|
13
|
+
# @param component_name [String] Name of the component for logging (default: "AIDP")
|
|
14
|
+
# @param skip_creation [Boolean] If true, skip directory creation entirely (default: false)
|
|
15
|
+
# @return [String] The actual directory path (may differ from input if fallback occurred)
|
|
16
|
+
def safe_mkdir_p(path, component_name: "AIDP", skip_creation: false)
|
|
17
|
+
return path if skip_creation
|
|
18
|
+
return path if Dir.exist?(path)
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
FileUtils.mkdir_p(path)
|
|
22
|
+
path
|
|
23
|
+
rescue SystemCallError => e
|
|
24
|
+
fallback = determine_fallback_path(path)
|
|
25
|
+
Kernel.warn "[#{component_name}] Cannot create directory #{path}: #{e.class}: #{e.message}"
|
|
26
|
+
Kernel.warn "[#{component_name}] Using fallback directory: #{fallback}"
|
|
27
|
+
|
|
28
|
+
# Try to create fallback directory
|
|
29
|
+
begin
|
|
30
|
+
FileUtils.mkdir_p(fallback) unless Dir.exist?(fallback)
|
|
31
|
+
rescue SystemCallError => e2
|
|
32
|
+
Kernel.warn "[#{component_name}] Fallback directory creation also failed: #{e2.class}: #{e2.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
fallback
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Determine a fallback directory path when primary creation fails
|
|
42
|
+
# Tries: $HOME/.aidp -> /tmp/aidp_<basename>
|
|
43
|
+
#
|
|
44
|
+
# @param original_path [String] The original path that failed
|
|
45
|
+
# @return [String] A fallback directory path
|
|
46
|
+
def determine_fallback_path(original_path)
|
|
47
|
+
# Extract meaningful name from path (e.g., ".aidp/jobs" -> "aidp_jobs")
|
|
48
|
+
base_name = extract_base_name(original_path)
|
|
49
|
+
|
|
50
|
+
# Try home directory first
|
|
51
|
+
begin
|
|
52
|
+
home = Dir.home
|
|
53
|
+
if home && !home.empty? && File.writable?(home)
|
|
54
|
+
return File.join(home, base_name)
|
|
55
|
+
end
|
|
56
|
+
rescue
|
|
57
|
+
# Ignore home directory errors, fall through to temp
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Fall back to temp directory
|
|
61
|
+
File.join(Dir.tmpdir, base_name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Extract a meaningful base name from a path for fallback naming
|
|
65
|
+
#
|
|
66
|
+
# @param path [String] The original path
|
|
67
|
+
# @return [String] A sanitized base name
|
|
68
|
+
def extract_base_name(path)
|
|
69
|
+
# Handle paths like "/project/.aidp/jobs" -> "aidp_jobs"
|
|
70
|
+
# or "/.aidp" -> ".aidp"
|
|
71
|
+
parts = path.split(File::SEPARATOR).reject(&:empty?)
|
|
72
|
+
|
|
73
|
+
if parts.include?(".aidp")
|
|
74
|
+
# If path contains .aidp, use .aidp and subdirectory
|
|
75
|
+
idx = parts.index(".aidp")
|
|
76
|
+
if idx && parts[idx + 1]
|
|
77
|
+
return "aidp_#{parts[idx + 1]}"
|
|
78
|
+
else
|
|
79
|
+
return ".aidp"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Fallback: use last directory name
|
|
84
|
+
parts.last || "aidp_storage"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Aidp
|
|
7
|
+
module Setup
|
|
8
|
+
module Devcontainer
|
|
9
|
+
# Manages backups of devcontainer.json before modifications
|
|
10
|
+
class BackupManager
|
|
11
|
+
class BackupError < StandardError; end
|
|
12
|
+
|
|
13
|
+
def initialize(project_dir)
|
|
14
|
+
@project_dir = project_dir
|
|
15
|
+
@backup_dir = File.join(project_dir, ".aidp", "backups", "devcontainer")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Create a backup of the devcontainer file
|
|
19
|
+
# @param source_path [String] Path to devcontainer.json to backup
|
|
20
|
+
# @param metadata [Hash] Optional metadata to store with backup
|
|
21
|
+
# @return [String] Path to backup file
|
|
22
|
+
def create_backup(source_path, metadata = {})
|
|
23
|
+
unless File.exist?(source_path)
|
|
24
|
+
raise BackupError, "Source file does not exist: #{source_path}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
ensure_backup_directory_exists
|
|
28
|
+
|
|
29
|
+
timestamp = Time.now.utc.strftime("%Y%m%d_%H%M%S")
|
|
30
|
+
backup_filename = "devcontainer-#{timestamp}.json"
|
|
31
|
+
backup_path = File.join(@backup_dir, backup_filename)
|
|
32
|
+
|
|
33
|
+
FileUtils.cp(source_path, backup_path)
|
|
34
|
+
|
|
35
|
+
# Create metadata file
|
|
36
|
+
if metadata.any?
|
|
37
|
+
metadata_path = "#{backup_path}.meta"
|
|
38
|
+
File.write(metadata_path, JSON.pretty_generate(metadata))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Aidp.log_info("backup_manager", "created backup",
|
|
42
|
+
source: source_path,
|
|
43
|
+
backup: backup_path)
|
|
44
|
+
|
|
45
|
+
backup_path
|
|
46
|
+
rescue => e
|
|
47
|
+
raise BackupError, "Failed to create backup: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# List all available backups
|
|
51
|
+
# @return [Array<Hash>] Array of backup info hashes
|
|
52
|
+
def list_backups
|
|
53
|
+
return [] unless File.directory?(@backup_dir)
|
|
54
|
+
|
|
55
|
+
Dir.glob(File.join(@backup_dir, "devcontainer-*.json"))
|
|
56
|
+
.reject { |f| f.end_with?(".meta") }
|
|
57
|
+
.map { |path| backup_info(path) }
|
|
58
|
+
.sort_by { |info| info[:timestamp] }
|
|
59
|
+
.reverse
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Restore a backup to the specified location
|
|
63
|
+
# @param backup_path [String] Path to backup file
|
|
64
|
+
# @param target_path [String] Where to restore the backup
|
|
65
|
+
# @param create_backup [Boolean] Create backup of target before restoring
|
|
66
|
+
# @return [Boolean] true if successful
|
|
67
|
+
def restore_backup(backup_path, target_path, create_backup: true)
|
|
68
|
+
unless File.exist?(backup_path)
|
|
69
|
+
raise BackupError, "Backup file does not exist: #{backup_path}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Backup current file before restoring
|
|
73
|
+
if create_backup && File.exist?(target_path)
|
|
74
|
+
create_backup(target_path, {
|
|
75
|
+
reason: "pre_restore",
|
|
76
|
+
restoring_from: backup_path
|
|
77
|
+
})
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
81
|
+
FileUtils.cp(backup_path, target_path)
|
|
82
|
+
|
|
83
|
+
Aidp.log_info("backup_manager", "restored backup",
|
|
84
|
+
backup: backup_path,
|
|
85
|
+
target: target_path)
|
|
86
|
+
|
|
87
|
+
true
|
|
88
|
+
rescue => e
|
|
89
|
+
raise BackupError, "Failed to restore backup: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Delete old backups, keeping only the N most recent
|
|
93
|
+
# @param keep_count [Integer] Number of backups to keep
|
|
94
|
+
# @return [Integer] Number of backups deleted
|
|
95
|
+
def cleanup_old_backups(keep_count = 10)
|
|
96
|
+
backups = list_backups
|
|
97
|
+
return 0 if backups.size <= keep_count
|
|
98
|
+
|
|
99
|
+
to_delete = backups[keep_count..]
|
|
100
|
+
deleted_count = 0
|
|
101
|
+
|
|
102
|
+
to_delete.each do |backup|
|
|
103
|
+
File.delete(backup[:path]) if File.exist?(backup[:path])
|
|
104
|
+
|
|
105
|
+
metadata_path = "#{backup[:path]}.meta"
|
|
106
|
+
File.delete(metadata_path) if File.exist?(metadata_path)
|
|
107
|
+
|
|
108
|
+
deleted_count += 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
Aidp.log_info("backup_manager", "cleaned up old backups",
|
|
112
|
+
deleted: deleted_count,
|
|
113
|
+
kept: keep_count)
|
|
114
|
+
|
|
115
|
+
deleted_count
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get the most recent backup
|
|
119
|
+
# @return [Hash, nil] Backup info or nil if no backups
|
|
120
|
+
def latest_backup
|
|
121
|
+
list_backups.first
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Calculate total size of all backups
|
|
125
|
+
# @return [Integer] Total size in bytes
|
|
126
|
+
def total_backup_size
|
|
127
|
+
return 0 unless File.directory?(@backup_dir)
|
|
128
|
+
|
|
129
|
+
Dir.glob(File.join(@backup_dir, "devcontainer-*.{json,meta}"))
|
|
130
|
+
.sum { |f| File.size(f) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def ensure_backup_directory_exists
|
|
136
|
+
FileUtils.mkdir_p(@backup_dir) unless File.directory?(@backup_dir)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def backup_info(path)
|
|
140
|
+
filename = File.basename(path)
|
|
141
|
+
timestamp_str = filename[/\d{8}_\d{6}/]
|
|
142
|
+
|
|
143
|
+
info = {
|
|
144
|
+
path: path,
|
|
145
|
+
filename: filename,
|
|
146
|
+
size: File.size(path),
|
|
147
|
+
created_at: File.mtime(path),
|
|
148
|
+
timestamp: parse_timestamp(timestamp_str)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# Load metadata if available
|
|
152
|
+
metadata_path = "#{path}.meta"
|
|
153
|
+
if File.exist?(metadata_path)
|
|
154
|
+
begin
|
|
155
|
+
metadata = JSON.parse(File.read(metadata_path))
|
|
156
|
+
info[:metadata] = metadata
|
|
157
|
+
rescue JSON::ParserError
|
|
158
|
+
# Ignore invalid metadata
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
info
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def parse_timestamp(timestamp_str)
|
|
166
|
+
return Time.now if timestamp_str.nil?
|
|
167
|
+
|
|
168
|
+
Time.strptime(timestamp_str, "%Y%m%d_%H%M%S")
|
|
169
|
+
rescue ArgumentError
|
|
170
|
+
Time.now
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|