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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +188 -0
- data/GUIDE.md +17 -10
- data/README.md +61 -54
- data/TECHNICAL.md +38 -15
- data/docs/codex-appserver.md +175 -0
- data/docs/configuration.md +118 -0
- data/docs/dispatch-telemetry.md +502 -0
- data/docs/events.md +132 -0
- data/guides/01_dispatch.md +31 -28
- data/guides/04_monitoring.md +10 -9
- data/guides/05_naming.md +18 -8
- data/lib/harnex/adapters/base.rb +10 -0
- data/lib/harnex/adapters/codex_appserver.rb +25 -0
- data/lib/harnex/artifact_report.rb +455 -6
- data/lib/harnex/cli.rb +3 -3
- data/lib/harnex/commands/artifact_report.rb +8 -7
- data/lib/harnex/commands/doctor.rb +38 -4
- data/lib/harnex/commands/history.rb +7 -3
- data/lib/harnex/commands/run.rb +55 -43
- data/lib/harnex/commands/status.rb +0 -2
- data/lib/harnex/commands/wait.rb +0 -1
- data/lib/harnex/config.rb +170 -0
- data/lib/harnex/core.rb +200 -21
- data/lib/harnex/dispatch_history.rb +16 -7
- data/lib/harnex/pricing.rb +135 -0
- data/lib/harnex/retention.rb +320 -0
- data/lib/harnex/runtime/session.rb +451 -166
- data/lib/harnex/terminal_status.rb +18 -21
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- metadata +9 -2
|
@@ -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
|