harnex 0.7.14 → 0.9.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 +132 -1
- data/GUIDE.md +17 -10
- data/README.md +30 -23
- data/TECHNICAL.md +26 -5
- data/docs/codex-appserver.md +172 -0
- data/docs/configuration.md +111 -0
- data/docs/dispatch-telemetry.md +461 -0
- data/docs/events.md +133 -0
- data/guides/01_dispatch.md +6 -0
- data/guides/04_monitoring.md +50 -3
- 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/commands/doctor.rb +38 -4
- data/lib/harnex/commands/history.rb +56 -2
- data/lib/harnex/commands/run.rb +77 -7
- data/lib/harnex/commands/status.rb +43 -4
- data/lib/harnex/commands/wait.rb +158 -61
- data/lib/harnex/commands/watch.rb +3 -2
- data/lib/harnex/config.rb +166 -0
- data/lib/harnex/core.rb +11 -8
- data/lib/harnex/dispatch_history.rb +107 -3
- data/lib/harnex/pricing.rb +135 -0
- data/lib/harnex/retention.rb +311 -0
- data/lib/harnex/runtime/session.rb +156 -13
- data/lib/harnex/terminal_status.rb +12 -1
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- metadata +9 -2
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
# Static price table for computing usage.cost_usd when an adapter reports
|
|
3
|
+
# tokens but no cost (plan 33 Phase 2, locked decision 6). Cost is computed,
|
|
4
|
+
# never guessed: an unknown provider/model/service tier or a missing token
|
|
5
|
+
# component leaves cost_usd null.
|
|
6
|
+
#
|
|
7
|
+
# ## Update procedure
|
|
8
|
+
#
|
|
9
|
+
# Rates are USD per 1M tokens, copied by hand from the provider pricing
|
|
10
|
+
# pages at the URLs below. To update:
|
|
11
|
+
#
|
|
12
|
+
# 1. Fetch the provider's pricing page and read the per-1M-token rates for
|
|
13
|
+
# input, cached input (cache read), and output.
|
|
14
|
+
# 2. Replace the entry's rates and set `as_of` to the date you read them.
|
|
15
|
+
# Add new models as new entries; never delete an entry solely because a
|
|
16
|
+
# model left the pricing page (old rows in dispatch.jsonl were priced
|
|
17
|
+
# against it — the `as_of` on each row records which table vintage
|
|
18
|
+
# applied).
|
|
19
|
+
# 3. Update the expected costs in test/harnex/pricing_test.rb — rate
|
|
20
|
+
# changes are meant to be conscious, test-visible edits.
|
|
21
|
+
# 4. Never backfill: rows written before a table change keep the cost they
|
|
22
|
+
# were written with.
|
|
23
|
+
#
|
|
24
|
+
# Models whose pricing varies by service tier use a nested `service_tiers`
|
|
25
|
+
# table. For those models a nil or unknown service tier is unpriceable. A
|
|
26
|
+
# `max_context_tokens_exclusive` entry also requires an observed context
|
|
27
|
+
# high-water below that boundary; Harnex leaves cost null when context is
|
|
28
|
+
# missing or entered a differently-priced long-context tier. Do not infer
|
|
29
|
+
# standard/flex/fast or context tier outside the recorded measurements.
|
|
30
|
+
# OpenAI documents `priority` as the fast alias for gpt-5.5 short-context
|
|
31
|
+
# pricing; Harnex maps that alias only where the source supports it.
|
|
32
|
+
#
|
|
33
|
+
# Known scheduled change: Anthropic's claude-sonnet-5 entry below carries
|
|
34
|
+
# introductory pricing ($2/$10) that ends 2026-08-31; standard pricing is
|
|
35
|
+
# $3/$15 (cache read $0.30) from 2026-09-01. Refresh the entry then.
|
|
36
|
+
#
|
|
37
|
+
# ## Token semantics (verified 2026-08-02, plan 33 Phase 2)
|
|
38
|
+
#
|
|
39
|
+
# What input_tokens contains depends on the CAPTURE PATH, not just the
|
|
40
|
+
# provider, so the caller passes `input_includes_cached:` (sourced from
|
|
41
|
+
# `Adapters::Base#usage_input_includes_cached?`):
|
|
42
|
+
#
|
|
43
|
+
# - codex app-server (input_includes_cached: true): the JSON
|
|
44
|
+
# TokenUsageBreakdown reports cachedInputTokens as a subset of
|
|
45
|
+
# inputTokens and reasoningOutputTokens as a subset of outputTokens
|
|
46
|
+
# (verified against test/fixtures/codex_schema/v2 and a live captured
|
|
47
|
+
# row: input 3,084,697 + output 17,705 == total 3,102,402 exactly).
|
|
48
|
+
# Billable input = input - cached.
|
|
49
|
+
# - codex PTY (false): the scraped TUI line `input=X (+ Y cached)` shows
|
|
50
|
+
# NON-cached input with cached as a separate additive count (fixture:
|
|
51
|
+
# total 106,867 == input 104,158 + output 2,709, cached 250,880 > input).
|
|
52
|
+
# Billable input = input; cached prices additively at the cached rate.
|
|
53
|
+
# - anthropic (false): the Messages API reports input_tokens EXCLUSIVE of
|
|
54
|
+
# cache reads (cache_read_input_tokens is a separate field). Cache
|
|
55
|
+
# WRITES (cache_creation_input_tokens, billed at 1.25x input) have no
|
|
56
|
+
# harnex usage field yet — the Phase 3 Claude usage producer must either
|
|
57
|
+
# fold them into input_tokens or extend this formula before pricing rows
|
|
58
|
+
# that include cache writes.
|
|
59
|
+
#
|
|
60
|
+
# Reasoning tokens are never priced separately on any path — they are
|
|
61
|
+
# already inside output_tokens.
|
|
62
|
+
module Pricing
|
|
63
|
+
PRICES = {
|
|
64
|
+
"openai" => {
|
|
65
|
+
# Source: https://developers.openai.com/api/docs/pricing
|
|
66
|
+
"gpt-5.3-codex" => { input: 1.75, cached_input: 0.175, output: 14.00, as_of: "2026-08-02" },
|
|
67
|
+
# Short-context service-tier rates read 2026-08-03. Long-context
|
|
68
|
+
# rates are deliberately absent until exact source pricing is known.
|
|
69
|
+
"gpt-5.5" => {
|
|
70
|
+
max_context_tokens_exclusive: 272_000,
|
|
71
|
+
service_tier_aliases: { "priority" => "fast" }.freeze,
|
|
72
|
+
service_tiers: {
|
|
73
|
+
"standard" => { input: 5.00, cached_input: 0.50, output: 30.00, as_of: "2026-08-03" },
|
|
74
|
+
"flex" => { input: 2.50, cached_input: 0.25, output: 15.00, as_of: "2026-08-03" },
|
|
75
|
+
"fast" => { input: 12.50, cached_input: 1.25, output: 75.00, as_of: "2026-08-03" }
|
|
76
|
+
}.freeze
|
|
77
|
+
}.freeze,
|
|
78
|
+
"gpt-5.2" => { input: 1.75, cached_input: 0.175, output: 14.00, as_of: "2026-08-02" },
|
|
79
|
+
"gpt-5.1" => { input: 1.25, cached_input: 0.125, output: 10.00, as_of: "2026-08-02" },
|
|
80
|
+
"gpt-5" => { input: 1.25, cached_input: 0.125, output: 10.00, as_of: "2026-08-02" },
|
|
81
|
+
"gpt-5-mini" => { input: 0.25, cached_input: 0.025, output: 2.00, as_of: "2026-08-02" }
|
|
82
|
+
}.freeze,
|
|
83
|
+
"anthropic" => {
|
|
84
|
+
# Source: https://claude.com/platform/api (cache read = 0.1x input)
|
|
85
|
+
"claude-fable-5" => { input: 10.00, cached_input: 1.00, output: 50.00, as_of: "2026-08-02" },
|
|
86
|
+
"claude-opus-5" => { input: 5.00, cached_input: 0.50, output: 25.00, as_of: "2026-08-02" },
|
|
87
|
+
"claude-sonnet-5" => { input: 2.00, cached_input: 0.20, output: 10.00, as_of: "2026-08-02" },
|
|
88
|
+
"claude-haiku-4-5" => { input: 1.00, cached_input: 0.10, output: 5.00, as_of: "2026-08-02" }
|
|
89
|
+
}.freeze
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
92
|
+
module_function
|
|
93
|
+
|
|
94
|
+
# Returns { cost_usd:, as_of: } or nil when the cost cannot be computed
|
|
95
|
+
# (unknown provider/model/service tier, or input/output token counts missing).
|
|
96
|
+
def compute(provider:, model:, input_tokens:, output_tokens:, cached_tokens: nil,
|
|
97
|
+
service_tier: nil, context_tokens: nil,
|
|
98
|
+
input_includes_cached: false)
|
|
99
|
+
model_entry = PRICES.dig(provider.to_s, model.to_s)
|
|
100
|
+
return nil unless model_entry
|
|
101
|
+
return nil unless context_priceable?(model_entry, context_tokens)
|
|
102
|
+
|
|
103
|
+
entry = rates_for_service_tier(model_entry, service_tier)
|
|
104
|
+
return nil unless entry
|
|
105
|
+
return nil unless input_tokens.is_a?(Numeric) && output_tokens.is_a?(Numeric)
|
|
106
|
+
|
|
107
|
+
cached = cached_tokens.is_a?(Numeric) ? cached_tokens : 0
|
|
108
|
+
billable_input = input_includes_cached ? input_tokens - cached : input_tokens
|
|
109
|
+
billable_input = 0 if billable_input.negative?
|
|
110
|
+
|
|
111
|
+
cost = (billable_input * entry[:input] +
|
|
112
|
+
cached * entry[:cached_input] +
|
|
113
|
+
output_tokens * entry[:output]) / 1_000_000.0
|
|
114
|
+
{ cost_usd: cost.round(6), as_of: entry[:as_of] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def context_priceable?(entry, context_tokens)
|
|
118
|
+
limit = entry[:max_context_tokens_exclusive]
|
|
119
|
+
return true unless limit
|
|
120
|
+
|
|
121
|
+
context_tokens.is_a?(Numeric) && context_tokens >= 0 && context_tokens < limit
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def rates_for_service_tier(entry, service_tier)
|
|
125
|
+
tiers = entry[:service_tiers]
|
|
126
|
+
return entry unless tiers
|
|
127
|
+
|
|
128
|
+
tier = service_tier.to_s
|
|
129
|
+
return nil if tier.empty?
|
|
130
|
+
|
|
131
|
+
aliases = entry[:service_tier_aliases] || {}
|
|
132
|
+
tiers[aliases.fetch(tier, tier)]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
}.freeze
|
|
12
|
+
THROTTLE_SECONDS = 3600
|
|
13
|
+
MAX_REPORTED_PATHS = 100
|
|
14
|
+
METADATA_PATH = File.join(STATE_DIR, "retention.json").freeze
|
|
15
|
+
LOCK_PATH = File.join(STATE_DIR, "retention.lock").freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def status(repo_root:, env: ENV, now: Time.now)
|
|
20
|
+
limits = Config.retention_limits(repo_root, env: env)
|
|
21
|
+
{
|
|
22
|
+
ok: true,
|
|
23
|
+
checked_at: now.utc.iso8601,
|
|
24
|
+
config: limits,
|
|
25
|
+
directories: DIRS.to_h do |key, dirname|
|
|
26
|
+
files = inventory(File.join(STATE_DIR, dirname))
|
|
27
|
+
[key, {
|
|
28
|
+
path: File.join(STATE_DIR, dirname),
|
|
29
|
+
count: files.length,
|
|
30
|
+
bytes: files.sum { |file| file.fetch(:bytes) },
|
|
31
|
+
max_age_days: limits.fetch(key).fetch("max_age_days"),
|
|
32
|
+
max_bytes: limits.fetch(key).fetch("max_bytes")
|
|
33
|
+
}]
|
|
34
|
+
end,
|
|
35
|
+
last_prune: last_prune_metadata
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def auto_prune(repo_root:, current_paths: [], env: ENV, now: Time.now)
|
|
40
|
+
prune(
|
|
41
|
+
repo_root: repo_root,
|
|
42
|
+
current_paths: current_paths,
|
|
43
|
+
env: env,
|
|
44
|
+
now: now,
|
|
45
|
+
dry_run: false,
|
|
46
|
+
force: false
|
|
47
|
+
)
|
|
48
|
+
rescue Config::ConfigError
|
|
49
|
+
raise
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
warn("harnex: retention prune failed: #{e.message}; run `harnex doctor --prune --dry-run` to inspect candidates")
|
|
52
|
+
{ ok: false, error: e.message }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def prune(repo_root:, current_paths: [], env: ENV, now: Time.now, dry_run: false, force: false)
|
|
56
|
+
limits = Config.retention_limits(repo_root, env: env)
|
|
57
|
+
with_lock do
|
|
58
|
+
last = last_prune_metadata
|
|
59
|
+
if !force && !dry_run && throttled?(last, now)
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
skipped: true,
|
|
63
|
+
reason: "throttled",
|
|
64
|
+
throttle_seconds: THROTTLE_SECONDS,
|
|
65
|
+
last_prune: last
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
protected = protected_paths(repo_root, current_paths)
|
|
70
|
+
report = {
|
|
71
|
+
ok: true,
|
|
72
|
+
skipped: false,
|
|
73
|
+
dry_run: dry_run,
|
|
74
|
+
timestamp: now.utc.iso8601,
|
|
75
|
+
directories: DIRS.to_h do |key, dirname|
|
|
76
|
+
[key.to_sym, prune_directory(
|
|
77
|
+
File.join(STATE_DIR, dirname),
|
|
78
|
+
limits.fetch(key),
|
|
79
|
+
protected,
|
|
80
|
+
now,
|
|
81
|
+
dry_run: dry_run
|
|
82
|
+
)]
|
|
83
|
+
end
|
|
84
|
+
}
|
|
85
|
+
persist_last_prune(report)
|
|
86
|
+
report
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def inventory(dir)
|
|
91
|
+
FileUtils.mkdir_p(dir)
|
|
92
|
+
base = File.expand_path(dir)
|
|
93
|
+
Dir.children(base).sort.filter_map do |name|
|
|
94
|
+
path = File.expand_path(File.join(base, name))
|
|
95
|
+
next unless path.start_with?("#{base}#{File::SEPARATOR}")
|
|
96
|
+
|
|
97
|
+
stat = File.lstat(path)
|
|
98
|
+
next unless stat.file?
|
|
99
|
+
|
|
100
|
+
{ path: path, bytes: stat.size, mtime: stat.mtime }
|
|
101
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def prune_directory(dir, limits, protected, now, dry_run:)
|
|
107
|
+
files = inventory(dir)
|
|
108
|
+
before_bytes = files.sum { |file| file.fetch(:bytes) }
|
|
109
|
+
protected_files = files.select { |file| protected.include?(file.fetch(:path)) }
|
|
110
|
+
deleted = []
|
|
111
|
+
deleted_paths = Set.new
|
|
112
|
+
projected_bytes = before_bytes
|
|
113
|
+
|
|
114
|
+
cutoff = now - limits.fetch("max_age_days") * 86_400
|
|
115
|
+
age_candidates = files.reject { |file| protected.include?(file.fetch(:path)) }
|
|
116
|
+
.select { |file| file.fetch(:mtime) < cutoff }
|
|
117
|
+
.sort_by { |file| [file.fetch(:mtime), file.fetch(:path)] }
|
|
118
|
+
age_candidates.each do |file|
|
|
119
|
+
next if deleted_paths.include?(file.fetch(:path))
|
|
120
|
+
|
|
121
|
+
if delete_candidate(file, dry_run: dry_run)
|
|
122
|
+
deleted << file
|
|
123
|
+
deleted_paths.add(file.fetch(:path))
|
|
124
|
+
projected_bytes -= file.fetch(:bytes)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
size_candidates = files.reject { |file| protected.include?(file.fetch(:path)) || deleted_paths.include?(file.fetch(:path)) }
|
|
129
|
+
.sort_by { |file| [file.fetch(:mtime), file.fetch(:path)] }
|
|
130
|
+
size_candidates.each do |file|
|
|
131
|
+
break if projected_bytes <= limits.fetch("max_bytes")
|
|
132
|
+
|
|
133
|
+
if delete_candidate(file, dry_run: dry_run)
|
|
134
|
+
deleted << file
|
|
135
|
+
deleted_paths.add(file.fetch(:path))
|
|
136
|
+
projected_bytes -= file.fetch(:bytes)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
after_bytes = dry_run ? projected_bytes : inventory(dir).sum { |file| file.fetch(:bytes) }
|
|
141
|
+
{
|
|
142
|
+
path: dir,
|
|
143
|
+
before_count: files.length,
|
|
144
|
+
before_bytes: before_bytes,
|
|
145
|
+
after_count: dry_run ? files.length - deleted.length : inventory(dir).length,
|
|
146
|
+
after_bytes: after_bytes,
|
|
147
|
+
deleted_count: deleted.length,
|
|
148
|
+
deleted_bytes: deleted.sum { |file| file.fetch(:bytes) },
|
|
149
|
+
deleted_paths: deleted.first(MAX_REPORTED_PATHS).map { |file| file.fetch(:path) },
|
|
150
|
+
deleted_paths_truncated: deleted.length > MAX_REPORTED_PATHS,
|
|
151
|
+
protected_count: protected_files.length,
|
|
152
|
+
protected_bytes: protected_files.sum { |file| file.fetch(:bytes) },
|
|
153
|
+
max_age_days: limits.fetch("max_age_days"),
|
|
154
|
+
max_bytes: limits.fetch("max_bytes"),
|
|
155
|
+
over_cap: after_bytes > limits.fetch("max_bytes")
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def delete_candidate(file, dry_run:)
|
|
160
|
+
return true if dry_run
|
|
161
|
+
|
|
162
|
+
File.delete(file.fetch(:path))
|
|
163
|
+
true
|
|
164
|
+
rescue Errno::ENOENT
|
|
165
|
+
true
|
|
166
|
+
rescue Errno::EACCES, Errno::EPERM, Errno::ENOTDIR
|
|
167
|
+
false
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def protected_paths(repo_root, current_paths)
|
|
171
|
+
paths = Set.new
|
|
172
|
+
current_paths.each { |path| protect(paths, path) }
|
|
173
|
+
protect_live_registry_paths(paths)
|
|
174
|
+
protect_live_start_row_paths(paths, repo_root)
|
|
175
|
+
paths
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def protect_live_registry_paths(paths)
|
|
179
|
+
return unless Dir.exist?(SESSIONS_DIR)
|
|
180
|
+
|
|
181
|
+
Dir.glob(File.join(SESSIONS_DIR, "*.json")).sort.each do |path|
|
|
182
|
+
data = JSON.parse(File.read(path))
|
|
183
|
+
next unless data["pid"] && Harnex.alive_pid?(data["pid"])
|
|
184
|
+
|
|
185
|
+
protect(paths, data["events_log_path"])
|
|
186
|
+
protect(paths, data["output_log_path"])
|
|
187
|
+
protect_derived_session_paths(paths, data)
|
|
188
|
+
rescue JSON::ParserError, ArgumentError, TypeError, Errno::ENOENT, Errno::EACCES
|
|
189
|
+
next
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def protect_live_start_row_paths(paths, repo_root)
|
|
194
|
+
history_paths = [
|
|
195
|
+
DispatchHistory.path_for(repo_root),
|
|
196
|
+
DispatchHistory.global_path
|
|
197
|
+
].uniq
|
|
198
|
+
|
|
199
|
+
history_paths.each do |path|
|
|
200
|
+
live_start_records(path).each do |record|
|
|
201
|
+
protect(paths, record["events_log_path"])
|
|
202
|
+
protect(paths, record["output_log_path"])
|
|
203
|
+
protect_derived_session_paths(paths, record)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def live_start_records(path)
|
|
209
|
+
return [] unless File.file?(path)
|
|
210
|
+
|
|
211
|
+
open = {}
|
|
212
|
+
File.foreach(path) do |line|
|
|
213
|
+
record = JSON.parse(line)
|
|
214
|
+
next unless record.is_a?(Hash)
|
|
215
|
+
|
|
216
|
+
if DispatchHistory.start_record?(record)
|
|
217
|
+
next unless DispatchHistory.same_host?(record)
|
|
218
|
+
next unless record["pid"] && Harnex.alive_pid?(record["pid"])
|
|
219
|
+
|
|
220
|
+
open[start_key(record)] = record
|
|
221
|
+
elsif DispatchHistory.end_record?(record)
|
|
222
|
+
open.delete_if { |_key, start| DispatchHistory.end_matches_start?(record, start) }
|
|
223
|
+
end
|
|
224
|
+
rescue JSON::ParserError, ArgumentError, TypeError
|
|
225
|
+
next
|
|
226
|
+
end
|
|
227
|
+
open.values
|
|
228
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
229
|
+
[]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def start_key(record)
|
|
233
|
+
session_id = record["session_id"].to_s
|
|
234
|
+
return "session:#{session_id}" unless session_id.empty?
|
|
235
|
+
|
|
236
|
+
"id:#{record['id']}\0#{record['started_at']}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def protect_derived_session_paths(paths, data)
|
|
240
|
+
repo = data["repo_root"].to_s
|
|
241
|
+
id = data["id"].to_s
|
|
242
|
+
return if repo.empty? || id.empty?
|
|
243
|
+
|
|
244
|
+
protect(paths, Harnex.events_log_path(repo, id))
|
|
245
|
+
protect(paths, Harnex.output_log_path(repo, id))
|
|
246
|
+
rescue StandardError
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def protect(paths, path)
|
|
251
|
+
text = path.to_s
|
|
252
|
+
return if text.empty?
|
|
253
|
+
|
|
254
|
+
paths.add(File.expand_path(text))
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def with_lock
|
|
258
|
+
FileUtils.mkdir_p(STATE_DIR)
|
|
259
|
+
File.open(LOCK_PATH, File::RDWR | File::CREAT, 0o644) do |file|
|
|
260
|
+
file.flock(File::LOCK_EX)
|
|
261
|
+
yield
|
|
262
|
+
ensure
|
|
263
|
+
file.flock(File::LOCK_UN) unless file.closed?
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def throttled?(last, now)
|
|
268
|
+
timestamp = last.is_a?(Hash) ? last["timestamp"].to_s : ""
|
|
269
|
+
return false if timestamp.empty?
|
|
270
|
+
|
|
271
|
+
now - Time.parse(timestamp) < THROTTLE_SECONDS
|
|
272
|
+
rescue ArgumentError
|
|
273
|
+
false
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def last_prune_metadata
|
|
277
|
+
data = JSON.parse(File.read(METADATA_PATH))
|
|
278
|
+
data["last_prune"]
|
|
279
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
280
|
+
nil
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def persist_last_prune(report)
|
|
284
|
+
payload = {
|
|
285
|
+
"last_prune" => {
|
|
286
|
+
"timestamp" => report.fetch(:timestamp),
|
|
287
|
+
"dry_run" => report.fetch(:dry_run),
|
|
288
|
+
"applied" => !report.fetch(:dry_run),
|
|
289
|
+
"directories" => report.fetch(:directories).transform_keys(&:to_s).transform_values do |stats|
|
|
290
|
+
{
|
|
291
|
+
"before_count" => stats.fetch(:before_count),
|
|
292
|
+
"before_bytes" => stats.fetch(:before_bytes),
|
|
293
|
+
"after_count" => stats.fetch(:after_count),
|
|
294
|
+
"after_bytes" => stats.fetch(:after_bytes),
|
|
295
|
+
"deleted_count" => stats.fetch(:deleted_count),
|
|
296
|
+
"deleted_bytes" => stats.fetch(:deleted_bytes),
|
|
297
|
+
"protected_count" => stats.fetch(:protected_count),
|
|
298
|
+
"protected_bytes" => stats.fetch(:protected_bytes),
|
|
299
|
+
"over_cap" => stats.fetch(:over_cap)
|
|
300
|
+
}
|
|
301
|
+
end
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
tmp = "#{METADATA_PATH}.tmp.#{Process.pid}"
|
|
305
|
+
File.write(tmp, JSON.pretty_generate(payload))
|
|
306
|
+
File.rename(tmp, METADATA_PATH)
|
|
307
|
+
rescue StandardError => e
|
|
308
|
+
warn("harnex: failed to persist retention metadata: #{e.message}")
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|