harnex 0.8.0 → 0.10.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.
@@ -0,0 +1,320 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "set"
4
+ require "time"
5
+
6
+ module Harnex
7
+ module Retention
8
+ DIRS = {
9
+ "events" => "events",
10
+ "output" => "output",
11
+ "receipts" => "receipts"
12
+ }.freeze
13
+ THROTTLE_SECONDS = 3600
14
+ MAX_REPORTED_PATHS = 100
15
+ METADATA_PATH = File.join(STATE_DIR, "retention.json").freeze
16
+ LOCK_PATH = File.join(STATE_DIR, "retention.lock").freeze
17
+
18
+ module_function
19
+
20
+ def status(repo_root:, env: ENV, now: Time.now)
21
+ limits = Config.retention_limits(repo_root, env: env)
22
+ {
23
+ ok: true,
24
+ checked_at: now.utc.iso8601,
25
+ config: limits,
26
+ directories: DIRS.to_h do |key, dirname|
27
+ files = inventory(File.join(STATE_DIR, dirname))
28
+ [key, {
29
+ path: File.join(STATE_DIR, dirname),
30
+ count: files.length,
31
+ bytes: files.sum { |file| file.fetch(:bytes) },
32
+ max_age_days: limits.fetch(key).fetch("max_age_days"),
33
+ max_bytes: limits.fetch(key).fetch("max_bytes")
34
+ }]
35
+ end,
36
+ last_prune: last_prune_metadata
37
+ }
38
+ end
39
+
40
+ def auto_prune(repo_root:, current_paths: [], env: ENV, now: Time.now)
41
+ prune(
42
+ repo_root: repo_root,
43
+ current_paths: current_paths,
44
+ env: env,
45
+ now: now,
46
+ dry_run: false,
47
+ force: false
48
+ )
49
+ rescue Config::ConfigError
50
+ raise
51
+ rescue StandardError => e
52
+ warn("harnex: retention prune failed: #{e.message}; run `harnex doctor --prune --dry-run` to inspect candidates")
53
+ { ok: false, error: e.message }
54
+ end
55
+
56
+ def prune(repo_root:, current_paths: [], env: ENV, now: Time.now, dry_run: false, force: false)
57
+ limits = Config.retention_limits(repo_root, env: env)
58
+ with_lock do
59
+ last = last_prune_metadata
60
+ if !force && !dry_run && throttled?(last, now)
61
+ return {
62
+ ok: true,
63
+ skipped: true,
64
+ reason: "throttled",
65
+ throttle_seconds: THROTTLE_SECONDS,
66
+ last_prune: last
67
+ }
68
+ end
69
+
70
+ protected = protected_paths(repo_root, current_paths)
71
+ report = {
72
+ ok: true,
73
+ skipped: false,
74
+ dry_run: dry_run,
75
+ timestamp: now.utc.iso8601,
76
+ directories: DIRS.to_h do |key, dirname|
77
+ [key.to_sym, prune_directory(
78
+ File.join(STATE_DIR, dirname),
79
+ limits.fetch(key),
80
+ protected,
81
+ now,
82
+ dry_run: dry_run
83
+ )]
84
+ end
85
+ }
86
+ persist_last_prune(report)
87
+ report
88
+ end
89
+ end
90
+
91
+ def inventory(dir)
92
+ FileUtils.mkdir_p(dir)
93
+ base = File.expand_path(dir)
94
+ Dir.children(base).sort.filter_map do |name|
95
+ path = File.expand_path(File.join(base, name))
96
+ next unless path.start_with?("#{base}#{File::SEPARATOR}")
97
+
98
+ stat = File.lstat(path)
99
+ next unless stat.file?
100
+
101
+ { path: path, bytes: stat.size, mtime: stat.mtime }
102
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR
103
+ nil
104
+ end
105
+ end
106
+
107
+ def prune_directory(dir, limits, protected, now, dry_run:)
108
+ files = inventory(dir)
109
+ before_bytes = files.sum { |file| file.fetch(:bytes) }
110
+ protected_files = files.select { |file| protected.include?(file.fetch(:path)) }
111
+ deleted = []
112
+ deleted_paths = Set.new
113
+ projected_bytes = before_bytes
114
+
115
+ cutoff = now - limits.fetch("max_age_days") * 86_400
116
+ age_candidates = files.reject { |file| protected.include?(file.fetch(:path)) }
117
+ .select { |file| file.fetch(:mtime) < cutoff }
118
+ .sort_by { |file| [file.fetch(:mtime), file.fetch(:path)] }
119
+ age_candidates.each do |file|
120
+ next if deleted_paths.include?(file.fetch(:path))
121
+
122
+ if delete_candidate(file, dry_run: dry_run)
123
+ deleted << file
124
+ deleted_paths.add(file.fetch(:path))
125
+ projected_bytes -= file.fetch(:bytes)
126
+ end
127
+ end
128
+
129
+ size_candidates = files.reject { |file| protected.include?(file.fetch(:path)) || deleted_paths.include?(file.fetch(:path)) }
130
+ .sort_by { |file| [file.fetch(:mtime), file.fetch(:path)] }
131
+ size_candidates.each do |file|
132
+ break if projected_bytes <= limits.fetch("max_bytes")
133
+
134
+ if delete_candidate(file, dry_run: dry_run)
135
+ deleted << file
136
+ deleted_paths.add(file.fetch(:path))
137
+ projected_bytes -= file.fetch(:bytes)
138
+ end
139
+ end
140
+
141
+ after_bytes = dry_run ? projected_bytes : inventory(dir).sum { |file| file.fetch(:bytes) }
142
+ {
143
+ path: dir,
144
+ before_count: files.length,
145
+ before_bytes: before_bytes,
146
+ after_count: dry_run ? files.length - deleted.length : inventory(dir).length,
147
+ after_bytes: after_bytes,
148
+ deleted_count: deleted.length,
149
+ deleted_bytes: deleted.sum { |file| file.fetch(:bytes) },
150
+ deleted_paths: deleted.first(MAX_REPORTED_PATHS).map { |file| file.fetch(:path) },
151
+ deleted_paths_truncated: deleted.length > MAX_REPORTED_PATHS,
152
+ protected_count: protected_files.length,
153
+ protected_bytes: protected_files.sum { |file| file.fetch(:bytes) },
154
+ max_age_days: limits.fetch("max_age_days"),
155
+ max_bytes: limits.fetch("max_bytes"),
156
+ over_cap: after_bytes > limits.fetch("max_bytes")
157
+ }
158
+ end
159
+
160
+ def delete_candidate(file, dry_run:)
161
+ return true if dry_run
162
+
163
+ File.delete(file.fetch(:path))
164
+ true
165
+ rescue Errno::ENOENT
166
+ true
167
+ rescue Errno::EACCES, Errno::EPERM, Errno::ENOTDIR
168
+ false
169
+ end
170
+
171
+ def protected_paths(repo_root, current_paths)
172
+ paths = Set.new
173
+ current_paths.each { |path| protect(paths, path) }
174
+ protect_live_registry_paths(paths)
175
+ protect_live_start_row_paths(paths, repo_root)
176
+ paths
177
+ end
178
+
179
+ def protect_live_registry_paths(paths)
180
+ return unless Dir.exist?(SESSIONS_DIR)
181
+
182
+ Dir.glob(File.join(SESSIONS_DIR, "*.json")).sort.each do |path|
183
+ data = JSON.parse(File.read(path))
184
+ next unless data["pid"] && Harnex.alive_pid?(data["pid"])
185
+
186
+ protect(paths, data["events_log_path"])
187
+ protect(paths, data["output_log_path"])
188
+ protect(paths, data["artifact_report_path"])
189
+ protect(paths, data["artifact_claims_path"])
190
+ protect_derived_session_paths(paths, data)
191
+ rescue JSON::ParserError, ArgumentError, TypeError, Errno::ENOENT, Errno::EACCES
192
+ next
193
+ end
194
+ end
195
+
196
+ def protect_live_start_row_paths(paths, repo_root)
197
+ history_paths = [
198
+ DispatchHistory.path_for(repo_root),
199
+ DispatchHistory.global_path
200
+ ].uniq
201
+
202
+ history_paths.each do |path|
203
+ live_start_records(path).each do |record|
204
+ protect(paths, record["events_log_path"])
205
+ protect(paths, record["output_log_path"])
206
+ protect(paths, record["artifact_report_path"])
207
+ protect(paths, record["artifact_claims_path"])
208
+ protect_derived_session_paths(paths, record)
209
+ end
210
+ end
211
+ end
212
+
213
+ def live_start_records(path)
214
+ return [] unless File.file?(path)
215
+
216
+ open = {}
217
+ File.foreach(path) do |line|
218
+ record = JSON.parse(line)
219
+ next unless record.is_a?(Hash)
220
+
221
+ if DispatchHistory.start_record?(record)
222
+ next unless DispatchHistory.same_host?(record)
223
+ next unless record["pid"] && Harnex.alive_pid?(record["pid"])
224
+
225
+ open[start_key(record)] = record
226
+ elsif DispatchHistory.end_record?(record)
227
+ open.delete_if { |_key, start| DispatchHistory.end_matches_start?(record, start) }
228
+ end
229
+ rescue JSON::ParserError, ArgumentError, TypeError
230
+ next
231
+ end
232
+ open.values
233
+ rescue Errno::ENOENT, Errno::EACCES
234
+ []
235
+ end
236
+
237
+ def start_key(record)
238
+ session_id = record["session_id"].to_s
239
+ return "session:#{session_id}" unless session_id.empty?
240
+
241
+ "id:#{record['id']}\0#{record['started_at']}"
242
+ end
243
+
244
+ def protect_derived_session_paths(paths, data)
245
+ repo = data["repo_root"].to_s
246
+ id = data["id"].to_s
247
+ return if repo.empty? || id.empty?
248
+
249
+ protect(paths, Harnex.events_log_path(repo, id))
250
+ protect(paths, Harnex.output_log_path(repo, id))
251
+ session_id = data["session_id"].to_s
252
+ unless session_id.empty?
253
+ receipt = Harnex::ArtifactReport.default_path(repo_root: repo, id: id, session_id: session_id)
254
+ protect(paths, receipt)
255
+ protect(paths, Harnex::ArtifactReport.claims_path(receipt))
256
+ end
257
+ rescue StandardError
258
+ nil
259
+ end
260
+
261
+ def protect(paths, path)
262
+ text = path.to_s
263
+ return if text.empty?
264
+
265
+ paths.add(File.expand_path(text))
266
+ end
267
+
268
+ def with_lock
269
+ FileUtils.mkdir_p(STATE_DIR)
270
+ File.open(LOCK_PATH, File::RDWR | File::CREAT, 0o644) do |file|
271
+ file.flock(File::LOCK_EX)
272
+ yield
273
+ ensure
274
+ file.flock(File::LOCK_UN) unless file.closed?
275
+ end
276
+ end
277
+
278
+ def throttled?(last, now)
279
+ timestamp = last.is_a?(Hash) ? last["timestamp"].to_s : ""
280
+ return false if timestamp.empty?
281
+
282
+ now - Time.parse(timestamp) < THROTTLE_SECONDS
283
+ rescue ArgumentError
284
+ false
285
+ end
286
+
287
+ def last_prune_metadata
288
+ data = JSON.parse(File.read(METADATA_PATH))
289
+ data["last_prune"]
290
+ rescue Errno::ENOENT, JSON::ParserError
291
+ nil
292
+ end
293
+
294
+ def persist_last_prune(report)
295
+ payload = {
296
+ "last_prune" => {
297
+ "timestamp" => report.fetch(:timestamp),
298
+ "dry_run" => report.fetch(:dry_run),
299
+ "applied" => !report.fetch(:dry_run),
300
+ "directories" => report.fetch(:directories).transform_keys(&:to_s).transform_values do |stats|
301
+ {
302
+ "before_count" => stats.fetch(:before_count),
303
+ "before_bytes" => stats.fetch(:before_bytes),
304
+ "after_count" => stats.fetch(:after_count),
305
+ "after_bytes" => stats.fetch(:after_bytes),
306
+ "deleted_count" => stats.fetch(:deleted_count),
307
+ "deleted_bytes" => stats.fetch(:deleted_bytes),
308
+ "protected_count" => stats.fetch(:protected_count),
309
+ "protected_bytes" => stats.fetch(:protected_bytes),
310
+ "over_cap" => stats.fetch(:over_cap)
311
+ }
312
+ end
313
+ }
314
+ }
315
+ Harnex.atomic_write_json(METADATA_PATH, payload)
316
+ rescue StandardError => e
317
+ warn("harnex: failed to persist retention metadata: #{e.message}")
318
+ end
319
+ end
320
+ end