harnex 0.8.0 → 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.
@@ -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
@@ -23,7 +23,7 @@ module Harnex
23
23
  terminal_tokens terminal_percent peak_tokens peak_percent
24
24
  ].freeze
25
25
  CONTEXT_SAMPLE_STATUSES = %w[observed estimated missing].freeze
26
- ATTEMPT_KINDS = %w[initial retry fix review superseding].freeze
26
+ ATTEMPT_KINDS = %w[initial retry fix review fallback superseding].freeze
27
27
  SESSION_SUMMARY_SIGNAL_FIELDS = %i[
28
28
  input_tokens output_tokens reasoning_tokens cached_tokens total_tokens
29
29
  agent_session_id cost_usd
@@ -183,6 +183,7 @@ module Harnex
183
183
 
184
184
  def run(validate_binary: true)
185
185
  validate_binary! if validate_binary
186
+ prune_retained_logs
186
187
  prepare_output_log
187
188
  prepare_events_log
188
189
 
@@ -1154,6 +1155,13 @@ module Harnex
1154
1155
  @output_log_failed = false
1155
1156
  end
1156
1157
 
1158
+ def prune_retained_logs
1159
+ Harnex::Retention.auto_prune(
1160
+ repo_root: repo_root,
1161
+ current_paths: [output_log_path, events_log_path]
1162
+ )
1163
+ end
1164
+
1157
1165
  def prepare_events_log
1158
1166
  @events_log&.close unless @events_log&.closed?
1159
1167
  @events_log = File.open(events_log_path, "ab")
@@ -1247,7 +1255,9 @@ module Harnex
1247
1255
  end
1248
1256
 
1249
1257
  def emit_summary_event
1250
- emit_event("summary", path: summary_out, exit: @exit_reason)
1258
+ payload = { path: dispatch_history_path, exit: @exit_reason }
1259
+ payload[:mirror_path] = summary_out if summary_out
1260
+ emit_event("summary", **payload)
1251
1261
  end
1252
1262
 
1253
1263
  def emit_attempt_finished(attempt)
@@ -1284,9 +1294,9 @@ module Harnex
1284
1294
  warn("harnex: failed to collect session-end telemetry: #{e.message}")
1285
1295
  end
1286
1296
  @exit_reason ||= classify_exit
1287
- record = build_summary_record
1297
+ record = DispatchHistory.build_record(self)
1298
+ append_dispatch_history_record(record)
1288
1299
  append_summary_record(record)
1289
- append_dispatch_history_record
1290
1300
  emit_summary_event
1291
1301
  emit_attempt_finished(record.fetch(:attempt))
1292
1302
  emit_exit_event
@@ -1581,10 +1591,30 @@ module Harnex
1581
1591
  cost_source ||= "provider_reported" if !@usage_summary[:cost_usd].nil?
1582
1592
  cost_source ||= summary_string(declared["cost_source"]) || "caller_estimate" if status == "estimated"
1583
1593
 
1594
+ cost_price_as_of = nil
1595
+ if values[:cost_usd].nil? && %w[observed zero].include?(status)
1596
+ priced = Pricing.compute(
1597
+ provider: summary_agent_provider,
1598
+ model: summary_model,
1599
+ input_tokens: values[:input_tokens],
1600
+ output_tokens: values[:output_tokens],
1601
+ cached_tokens: values[:cached_tokens],
1602
+ service_tier: summary_service_tier,
1603
+ context_tokens: @context_summary[:peak_tokens],
1604
+ input_includes_cached: adapter.usage_input_includes_cached?
1605
+ )
1606
+ if priced
1607
+ values[:cost_usd] = priced[:cost_usd]
1608
+ cost_source = "price_table"
1609
+ cost_price_as_of = priced[:as_of]
1610
+ end
1611
+ end
1612
+
1584
1613
  {
1585
1614
  "status" => status,
1586
1615
  "cost_usd" => values[:cost_usd],
1587
1616
  "cost_source" => cost_source,
1617
+ "cost_price_as_of" => cost_price_as_of,
1588
1618
  "input_tokens" => values[:input_tokens],
1589
1619
  "output_tokens" => values[:output_tokens],
1590
1620
  "cached_input_tokens" => values[:cached_tokens],
@@ -1762,6 +1792,101 @@ module Harnex
1762
1792
  @exit_reason == "success"
1763
1793
  end
1764
1794
 
1795
+ def attempt_chain_summary
1796
+ @attempt_chain_summary ||= begin
1797
+ current = {
1798
+ id: id,
1799
+ parent_dispatch_id: summary_string(meta_hash["parent_dispatch_id"]) || @parent_harnex_id,
1800
+ kind: summary_attempt_kind,
1801
+ outcome: summary_attempt_succeeded? ? "succeeded" : "failed"
1802
+ }
1803
+ index = historical_attempt_index
1804
+ nodes = [current]
1805
+ seen = {}
1806
+ seen[current[:id]] = true if current[:id]
1807
+ parent = current[:parent_dispatch_id]
1808
+ immediate_parent = nil
1809
+
1810
+ while parent && !seen[parent]
1811
+ record = index[parent]
1812
+ break unless record
1813
+
1814
+ node = historical_attempt_node(record)
1815
+ break unless node
1816
+
1817
+ immediate_parent ||= node
1818
+ nodes << node
1819
+ seen[node[:id]] = true if node[:id]
1820
+ parent = node[:parent_dispatch_id]
1821
+ end
1822
+
1823
+ {
1824
+ attempts_total: nodes.length,
1825
+ attempts_succeeded: nodes.count { |node| node[:outcome] == "succeeded" },
1826
+ attempts_failed: nodes.count { |node| node[:outcome] == "failed" },
1827
+ fallback_triggered: nodes.any? { |node| node[:kind] == "fallback" },
1828
+ recovered: current[:outcome] == "succeeded" && immediate_parent&.dig(:outcome) == "failed"
1829
+ }
1830
+ end
1831
+ end
1832
+
1833
+ def historical_attempt_index
1834
+ path = dispatch_history_path
1835
+ return {} unless File.file?(path)
1836
+
1837
+ records = {}
1838
+ duplicates = {}
1839
+ File.foreach(path) do |line|
1840
+ record = JSON.parse(line)
1841
+ next unless DispatchHistory.end_record?(record)
1842
+
1843
+ dispatch_id = summary_string(record["id"])
1844
+ next unless dispatch_id
1845
+
1846
+ if records.key?(dispatch_id)
1847
+ records.delete(dispatch_id)
1848
+ duplicates[dispatch_id] = true
1849
+ elsif !duplicates.key?(dispatch_id)
1850
+ records[dispatch_id] = record
1851
+ end
1852
+ rescue JSON::ParserError
1853
+ next
1854
+ end
1855
+ records
1856
+ rescue StandardError
1857
+ {}
1858
+ end
1859
+
1860
+ def historical_attempt_node(record)
1861
+ attempt = record["attempt"].is_a?(Hash) ? record["attempt"] : {}
1862
+ meta = record["meta"].is_a?(Hash) ? record["meta"] : {}
1863
+ dispatch_id = summary_string(record["id"])
1864
+ return nil unless dispatch_id
1865
+
1866
+ {
1867
+ id: dispatch_id,
1868
+ parent_dispatch_id: summary_string(attempt["parent_dispatch_id"]) || summary_string(meta["parent_dispatch_id"]),
1869
+ kind: summary_string(attempt["kind"]),
1870
+ outcome: historical_attempt_outcome(record, attempt)
1871
+ }
1872
+ end
1873
+
1874
+ def historical_attempt_outcome(record, attempt)
1875
+ case summary_string(attempt["status"])
1876
+ when "succeeded"
1877
+ "succeeded"
1878
+ when "failed"
1879
+ "failed"
1880
+ else
1881
+ case summary_string(record["status"])
1882
+ when "completed"
1883
+ "succeeded"
1884
+ when "failed", "timeout", "killed"
1885
+ "failed"
1886
+ end
1887
+ end
1888
+ end
1889
+
1765
1890
  def accepted_throughput_tokens_per_s(total_tokens, duration_s, accepted)
1766
1891
  return nil unless accepted && total_tokens.is_a?(Numeric) && duration_s.to_f.positive?
1767
1892
 
@@ -1785,6 +1910,7 @@ module Harnex
1785
1910
  def build_summary_reliability
1786
1911
  counters = reliability_event_counters
1787
1912
  real_disconnections = counters[:disconnections].to_i
1913
+ chain = attempt_chain_summary
1788
1914
  {
1789
1915
  "adapter_close" => summary_adapter_close(real_disconnections),
1790
1916
  "real_disconnections" => real_disconnections,
@@ -1792,7 +1918,7 @@ module Harnex
1792
1918
  "stalls" => counters[:stalls].to_i,
1793
1919
  "force_resumes" => counters[:force_resumes].to_i,
1794
1920
  "compactions" => counters[:compactions].to_i,
1795
- "recovered" => false
1921
+ "recovered" => chain[:recovered]
1796
1922
  }
1797
1923
  end
1798
1924
 
@@ -1801,7 +1927,7 @@ module Harnex
1801
1927
  output_measurements = summary_output_measurements
1802
1928
  accepted = outcome["status"] == "accepted"
1803
1929
  duration_s = @ended_at ? (@ended_at - @started_at).to_i : nil
1804
- attempt_succeeded = summary_attempt_succeeded?
1930
+ chain = attempt_chain_summary
1805
1931
  total_tokens = @usage_summary[:total_tokens]
1806
1932
 
1807
1933
  actual = {
@@ -1839,9 +1965,9 @@ module Harnex
1839
1965
  event_records: @events_log_seq,
1840
1966
  output_log_path: output_log_path,
1841
1967
  events_log_path: events_log_path,
1842
- attempts_total: 1,
1843
- attempts_succeeded: attempt_succeeded ? 1 : 0,
1844
- attempts_failed: attempt_succeeded ? 0 : 1,
1968
+ attempts_total: chain[:attempts_total],
1969
+ attempts_succeeded: chain[:attempts_succeeded],
1970
+ attempts_failed: chain[:attempts_failed],
1845
1971
  retry_count: counters[:retries],
1846
1972
  throttle_429_count: counters[:throttle_429],
1847
1973
  disconnect_count: counters[:disconnections],
@@ -1849,7 +1975,7 @@ module Harnex
1849
1975
  throughput_successes_per_h: accepted_throughput_successes_per_h(duration_s, accepted),
1850
1976
  retry_tax_pct: counters[:retries].to_i.zero? ? 0.0 : nil,
1851
1977
  unattributed: attribution["status"] != "complete",
1852
- fallback_triggered: false
1978
+ fallback_triggered: chain[:fallback_triggered]
1853
1979
  }
1854
1980
  actual
1855
1981
  end
@@ -1983,6 +2109,8 @@ module Harnex
1983
2109
  meta.is_a?(Hash) ? meta : {}
1984
2110
  end
1985
2111
 
2112
+ # Explicit-only mirror: the identical end record lands here in addition
2113
+ # to the tracked stream when --summary-out is configured.
1986
2114
  def append_summary_record(record)
1987
2115
  return unless summary_out
1988
2116
 
@@ -2001,8 +2129,8 @@ module Harnex
2001
2129
  warn("harnex: failed to write dispatch start record: #{e.message}")
2002
2130
  end
2003
2131
 
2004
- def append_dispatch_history_record
2005
- DispatchHistory.append(dispatch_history_path, DispatchHistory.build_record(self))
2132
+ def append_dispatch_history_record(record)
2133
+ DispatchHistory.append(dispatch_history_path, record)
2006
2134
  rescue StandardError => e
2007
2135
  warn("harnex: failed to write dispatch history: #{e.message}")
2008
2136
  end
@@ -80,7 +80,16 @@ module Harnex
80
80
  next unless record.is_a?(Hash)
81
81
  next if DispatchHistory.start_record?(record)
82
82
 
83
- if summary_record?(record) && record.dig("meta", "id").to_s == id
83
+ # Branch on record_type first: a v2 end row carries both the thin
84
+ # envelope and the rich sections, so it resolves as summary and
85
+ # history in one shot — never double-counted via the legacy
86
+ # duck-types below, which stay for pre-v2 files.
87
+ if record["record_type"] == "dispatch_end"
88
+ next unless record["id"].to_s == id
89
+
90
+ history_record = record
91
+ summary_record = record if summary_record?(record)
92
+ elsif summary_record?(record) && record.dig("meta", "id").to_s == id
84
93
  summary_record = record
85
94
  elsif history_record?(record) && record["id"].to_s == id
86
95
  history_record = record
@@ -98,6 +107,7 @@ module Harnex
98
107
  record["meta"].is_a?(Hash) && record["actual"].is_a?(Hash)
99
108
  end
100
109
 
110
+ # Legacy thin end rows predate record_type.
101
111
  def history_record?(record)
102
112
  record["schema_version"] == 1 && record.key?("status")
103
113
  end
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.8.0"
3
- RELEASE_DATE = "2026-08-02"
2
+ VERSION = "0.9.0"
3
+ RELEASE_DATE = "2026-08-03"
4
4
  end
data/lib/harnex.rb CHANGED
@@ -5,6 +5,9 @@ require "open3"
5
5
  require_relative "harnex/version"
6
6
  require_relative "harnex/core"
7
7
  require_relative "harnex/dispatch_history"
8
+ require_relative "harnex/config"
9
+ require_relative "harnex/retention"
10
+ require_relative "harnex/pricing"
8
11
  require_relative "harnex/terminal_status"
9
12
  require_relative "harnex/artifact_report"
10
13
  require_relative "harnex/context_telemetry"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harnex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jikku Jose
@@ -26,6 +26,10 @@ files:
26
26
  - TECHNICAL.md
27
27
  - bin/gem-push
28
28
  - bin/harnex
29
+ - docs/codex-appserver.md
30
+ - docs/configuration.md
31
+ - docs/dispatch-telemetry.md
32
+ - docs/events.md
29
33
  - guides/01_dispatch.md
30
34
  - guides/02_chain.md
31
35
  - guides/03_buddy.md
@@ -60,10 +64,13 @@ files:
60
64
  - lib/harnex/commands/wait.rb
61
65
  - lib/harnex/commands/watch.rb
62
66
  - lib/harnex/commands/watch_presets.rb
67
+ - lib/harnex/config.rb
63
68
  - lib/harnex/context_telemetry.rb
64
69
  - lib/harnex/core.rb
65
70
  - lib/harnex/dispatch_history.rb
66
71
  - lib/harnex/orchestration.rb
72
+ - lib/harnex/pricing.rb
73
+ - lib/harnex/retention.rb
67
74
  - lib/harnex/runtime/api_server.rb
68
75
  - lib/harnex/runtime/file_change_hook.rb
69
76
  - lib/harnex/runtime/inbox.rb