harnex 0.9.0 → 0.10.1
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 +122 -0
- data/README.md +38 -38
- data/TECHNICAL.md +17 -15
- data/docs/codex-appserver.md +16 -13
- data/docs/configuration.md +11 -4
- data/docs/dispatch-telemetry.md +189 -112
- data/docs/events.md +2 -3
- data/guides/01_dispatch.md +29 -32
- data/guides/04_monitoring.md +4 -4
- data/lib/harnex/artifact_report.rb +455 -6
- data/lib/harnex/cli.rb +11 -3
- data/lib/harnex/commands/artifact_report.rb +8 -7
- data/lib/harnex/commands/doctor.rb +1 -1
- data/lib/harnex/commands/run.rb +22 -37
- data/lib/harnex/commands/status.rb +0 -2
- data/lib/harnex/commands/telemetry.rb +110 -0
- data/lib/harnex/commands/wait.rb +0 -1
- data/lib/harnex/config.rb +6 -2
- data/lib/harnex/core.rb +200 -14
- data/lib/harnex/dispatch_history.rb +3 -3
- data/lib/harnex/retention.rb +13 -4
- data/lib/harnex/runtime/session.rb +317 -160
- data/lib/harnex/telemetry_reconciler.rb +494 -0
- data/lib/harnex/terminal_status.rb +7 -20
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +2 -0
- metadata +4 -2
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "set"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Harnex
|
|
7
|
+
class TelemetryReconciler
|
|
8
|
+
SCHEMA = "harnex.telemetry_reconcile.v1"
|
|
9
|
+
FAMILY_KEYS = %w[v2_start v2_end v1_end legacy_rich legacy_unknown].freeze
|
|
10
|
+
RICH_KEYS = %w[predicted agent usage context attribution outcome attempt reliability].freeze
|
|
11
|
+
MAX_DIAGNOSTICS = 50
|
|
12
|
+
|
|
13
|
+
Result = Struct.new(:report, :exitstatus, keyword_init: true)
|
|
14
|
+
Row = Struct.new(:record, :path, :line, :family, :identity, :match_identity, :sort_key, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
def initialize(command:, canonical:, sources:, apply: false)
|
|
17
|
+
@command = command
|
|
18
|
+
@canonical = canonical
|
|
19
|
+
@sources = sources
|
|
20
|
+
@apply = apply
|
|
21
|
+
@diagnostics = []
|
|
22
|
+
@diagnostics_truncated = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run
|
|
26
|
+
base = analyze
|
|
27
|
+
return finish(base) unless @apply && writable?(base)
|
|
28
|
+
|
|
29
|
+
appended, locked = append_missing_under_lock(base[:missing_rows])
|
|
30
|
+
locked[:appended] = appended
|
|
31
|
+
return finish(locked) unless locked[:fatal].empty? && locked[:conflict_rows].empty?
|
|
32
|
+
|
|
33
|
+
post = analyze
|
|
34
|
+
post[:appended] = appended
|
|
35
|
+
finish(post)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def analyze
|
|
41
|
+
reset_diagnostics
|
|
42
|
+
canonical = load_canonical(@canonical)
|
|
43
|
+
sources = discover_sources(@sources, @canonical)
|
|
44
|
+
source_rows = sources[:rows]
|
|
45
|
+
conflict_rows = sources[:conflicts]
|
|
46
|
+
missing_rows = []
|
|
47
|
+
present = 0
|
|
48
|
+
|
|
49
|
+
unless canonical[:fatal].empty? || source_rows.empty?
|
|
50
|
+
return analysis(canonical, sources, present, missing_rows, conflict_rows)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
source_rows.each do |row|
|
|
54
|
+
match = find_match(row, canonical[:recoverable])
|
|
55
|
+
if match.nil?
|
|
56
|
+
missing_rows << row
|
|
57
|
+
diagnostic("missing #{identity_label(row)} at #{row.path}:#{row.line}")
|
|
58
|
+
elsif rows_payload_equal?(row, match)
|
|
59
|
+
present += 1
|
|
60
|
+
else
|
|
61
|
+
conflict_rows << [row, match]
|
|
62
|
+
diagnostic("identity conflict #{identity_label(row)} at #{row.path}:#{row.line} vs #{match.path}:#{match.line}")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
analysis(canonical, sources, present, missing_rows, conflict_rows)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def analysis(canonical, sources, present, missing_rows, conflict_rows)
|
|
70
|
+
{
|
|
71
|
+
canonical_rows: canonical[:rows],
|
|
72
|
+
families: canonical[:families],
|
|
73
|
+
recoverable: canonical[:recoverable],
|
|
74
|
+
sources: sources[:summary],
|
|
75
|
+
fatal: canonical[:fatal] + sources[:fatal],
|
|
76
|
+
present: present,
|
|
77
|
+
missing_rows: dedupe_rows(missing_rows),
|
|
78
|
+
conflict_rows: conflict_rows,
|
|
79
|
+
open_starts: canonical[:open_starts],
|
|
80
|
+
appended: 0
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def finish(state)
|
|
85
|
+
state[:fatal].each { |message| diagnostic(message) }
|
|
86
|
+
status =
|
|
87
|
+
if state[:fatal].any?
|
|
88
|
+
"corrupt"
|
|
89
|
+
elsif state[:conflict_rows].any?
|
|
90
|
+
"conflict"
|
|
91
|
+
elsif state[:missing_rows].any?
|
|
92
|
+
"drift"
|
|
93
|
+
else
|
|
94
|
+
"clean"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
report = {
|
|
98
|
+
schema: SCHEMA,
|
|
99
|
+
command: @command,
|
|
100
|
+
status: status,
|
|
101
|
+
canonical: @canonical,
|
|
102
|
+
canonical_rows: state[:canonical_rows],
|
|
103
|
+
families: state[:families],
|
|
104
|
+
sources: state[:sources],
|
|
105
|
+
present: state[:present],
|
|
106
|
+
missing: state[:missing_rows].length,
|
|
107
|
+
conflicts: state[:conflict_rows].length,
|
|
108
|
+
open_starts: state[:open_starts],
|
|
109
|
+
appended: state[:appended],
|
|
110
|
+
diagnostics: @diagnostics,
|
|
111
|
+
diagnostics_truncated: @diagnostics_truncated
|
|
112
|
+
}
|
|
113
|
+
Result.new(report: report, exitstatus: status == "clean" ? 0 : 1)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def writable?(state) = state[:fatal].empty? && state[:conflict_rows].empty? && state[:missing_rows].any?
|
|
117
|
+
|
|
118
|
+
def load_canonical(path)
|
|
119
|
+
rows = []
|
|
120
|
+
fatal = []
|
|
121
|
+
families = FAMILY_KEYS.to_h { |key| [key, 0] }
|
|
122
|
+
starts = {}
|
|
123
|
+
ends = {}
|
|
124
|
+
recoverable = []
|
|
125
|
+
|
|
126
|
+
read_jsonl(path).each do |entry|
|
|
127
|
+
if entry[:error]
|
|
128
|
+
fatal << "malformed canonical JSON at #{path}:#{entry[:line]}"
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
record = entry[:record]
|
|
132
|
+
rows << record
|
|
133
|
+
unless record.is_a?(Hash)
|
|
134
|
+
fatal << "canonical JSON object required at #{path}:#{entry[:line]}"
|
|
135
|
+
next
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
case canonical_family(record)
|
|
139
|
+
when :v2_start
|
|
140
|
+
families["v2_start"] += 1
|
|
141
|
+
validate_v2(record, path, entry[:line], fatal) do |identity|
|
|
142
|
+
add_unique(starts, identity, record, path, entry[:line], fatal, "v2 start")
|
|
143
|
+
end
|
|
144
|
+
when :v2_end
|
|
145
|
+
families["v2_end"] += 1
|
|
146
|
+
validate_v2(record, path, entry[:line], fatal) do |identity|
|
|
147
|
+
add_unique(ends, identity, record, path, entry[:line], fatal, "v2 end")
|
|
148
|
+
recoverable << build_row(record, path, entry[:line], :v2)
|
|
149
|
+
end
|
|
150
|
+
when :v1_end
|
|
151
|
+
families["v1_end"] += 1
|
|
152
|
+
when :legacy_rich
|
|
153
|
+
families["legacy_rich"] += 1
|
|
154
|
+
recoverable << build_row(record, path, entry[:line], :legacy)
|
|
155
|
+
else
|
|
156
|
+
families["legacy_unknown"] += 1
|
|
157
|
+
diagnostic("legacy_unknown at #{path}:#{entry[:line]}")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
ends.each do |identity, row|
|
|
162
|
+
fatal << "unpaired v2 end #{identity.join('|')} at #{row.path}:#{row.line}" unless starts.key?(identity)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
{
|
|
166
|
+
rows: rows.length,
|
|
167
|
+
families: families,
|
|
168
|
+
recoverable: recoverable,
|
|
169
|
+
fatal: fatal,
|
|
170
|
+
open_starts: (starts.keys - ends.keys).length
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def validate_v2(record, path, line, fatal)
|
|
175
|
+
unless record["schema_version"] == Harnex::DispatchHistory::SCHEMA_VERSION
|
|
176
|
+
fatal << "invalid v2 schema_version at #{path}:#{line}"
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
id = record["id"].to_s
|
|
180
|
+
session_id = record["session_id"].to_s
|
|
181
|
+
started_at = normalized_time(record["started_at"])
|
|
182
|
+
if id.empty? || session_id.empty? || started_at.nil?
|
|
183
|
+
fatal << "invalid v2 identity at #{path}:#{line}"
|
|
184
|
+
return
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
yield(["v2", session_id, id, started_at])
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def add_unique(index, identity, record, path, line, fatal, label)
|
|
191
|
+
prior = index[identity]
|
|
192
|
+
if prior
|
|
193
|
+
kind = payload_equal?(record, prior.record) ? "duplicate v2 identity" : "identity conflict"
|
|
194
|
+
fatal << "#{kind} for #{label} #{identity.join('|')} at #{path}:#{line}"
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
index[identity] = Row.new(record: record, path: path, line: line)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def discover_sources(paths, canonical_path)
|
|
202
|
+
unique = paths.map { |path| File.expand_path(path) }.uniq
|
|
203
|
+
files = unique.flat_map { |path| source_files(path, canonical_path) }
|
|
204
|
+
rows = []
|
|
205
|
+
fatal = []
|
|
206
|
+
|
|
207
|
+
files.each do |file|
|
|
208
|
+
parsed = parse_source_file(file[:path], explicit: file[:explicit])
|
|
209
|
+
fatal.concat(parsed[:fatal])
|
|
210
|
+
rows.concat(parsed[:rows])
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
conflicts = source_conflicts(rows)
|
|
214
|
+
conflicts.each { |left, right| diagnostic("identity conflict #{identity_label(left)} at #{left.path}:#{left.line} vs #{right.path}:#{right.line}") }
|
|
215
|
+
|
|
216
|
+
{
|
|
217
|
+
rows: dedupe_source_rows(rows),
|
|
218
|
+
fatal: fatal,
|
|
219
|
+
summary: {
|
|
220
|
+
"paths" => unique.length,
|
|
221
|
+
"files_scanned" => files.length,
|
|
222
|
+
"candidates" => rows.length
|
|
223
|
+
},
|
|
224
|
+
conflicts: conflicts
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def source_files(path, canonical_path)
|
|
229
|
+
canonical_real = realpath_or_expand(canonical_path)
|
|
230
|
+
if File.file?(path) && !File.symlink?(path)
|
|
231
|
+
return [{ path: path, explicit: true }]
|
|
232
|
+
end
|
|
233
|
+
return [] unless File.directory?(path)
|
|
234
|
+
|
|
235
|
+
files = []
|
|
236
|
+
Dir.children(path).sort.each do |child|
|
|
237
|
+
child_path = File.join(path, child)
|
|
238
|
+
next if child == ".git" || File.symlink?(child_path)
|
|
239
|
+
|
|
240
|
+
if File.directory?(child_path)
|
|
241
|
+
files.concat(source_files(child_path, canonical_path))
|
|
242
|
+
elsif File.file?(child_path) && child_path.match?(/\.(jsonl?|JSONL?)\z/)
|
|
243
|
+
next if realpath_or_expand(child_path) == canonical_real
|
|
244
|
+
|
|
245
|
+
files << { path: child_path, explicit: false }
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
files
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def parse_source_file(path, explicit:)
|
|
252
|
+
parsed = parse_whole_json(path)
|
|
253
|
+
entries = parsed ? json_entries(parsed, path) : read_jsonl(path)
|
|
254
|
+
errors = entries.select { |entry| entry[:error] }
|
|
255
|
+
rows = entries.filter_map do |entry|
|
|
256
|
+
next if entry[:error]
|
|
257
|
+
|
|
258
|
+
source_candidate(entry[:record], path, entry[:line])
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
fatal = []
|
|
262
|
+
if explicit && errors.any?
|
|
263
|
+
fatal << "malformed source telemetry at #{path}:#{errors.first[:line]}"
|
|
264
|
+
elsif !explicit && rows.any? && errors.any?
|
|
265
|
+
fatal << "malformed source telemetry at #{path}:#{errors.first[:line]}"
|
|
266
|
+
end
|
|
267
|
+
{ rows: rows, fatal: fatal }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def parse_whole_json(path)
|
|
271
|
+
text = File.read(path)
|
|
272
|
+
return nil if text.strip.empty?
|
|
273
|
+
|
|
274
|
+
JSON.parse(text)
|
|
275
|
+
rescue JSON::ParserError, Errno::ENOENT, Errno::EACCES
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def json_entries(value, path)
|
|
280
|
+
records = value.is_a?(Array) ? value : [value]
|
|
281
|
+
records.each_with_index.map { |record, index| { record: record, line: index + 1, path: path } }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def read_jsonl(path)
|
|
285
|
+
return [] unless File.file?(path)
|
|
286
|
+
|
|
287
|
+
File.readlines(path, chomp: true).each_with_index.filter_map do |line, index|
|
|
288
|
+
next if line.strip.empty?
|
|
289
|
+
|
|
290
|
+
begin
|
|
291
|
+
{ record: JSON.parse(line), line: index + 1, path: path }
|
|
292
|
+
rescue JSON::ParserError => error
|
|
293
|
+
{ error: error, line: index + 1, path: path }
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def canonical_family(record)
|
|
299
|
+
if record["schema_version"] == 2 && record["record_type"] == "dispatch_start"
|
|
300
|
+
:v2_start
|
|
301
|
+
elsif record["schema_version"] == 2 && record["record_type"] == "dispatch_end"
|
|
302
|
+
:v2_end
|
|
303
|
+
elsif record["schema_version"] == 1 && record.key?("status") && !record.key?("record_type")
|
|
304
|
+
:v1_end
|
|
305
|
+
elsif legacy_rich?(record)
|
|
306
|
+
:legacy_rich
|
|
307
|
+
else
|
|
308
|
+
:legacy_unknown
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def source_candidate(record, path, line)
|
|
313
|
+
if v2_rich_end?(record)
|
|
314
|
+
build_row(record, path, line, :v2)
|
|
315
|
+
elsif legacy_rich?(record)
|
|
316
|
+
build_row(record, path, line, :legacy)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def v2_rich_end?(record)
|
|
321
|
+
record.is_a?(Hash) &&
|
|
322
|
+
record["schema_version"] == 2 &&
|
|
323
|
+
record["record_type"] == "dispatch_end" &&
|
|
324
|
+
!record["id"].to_s.empty? &&
|
|
325
|
+
!record["session_id"].to_s.empty? &&
|
|
326
|
+
normalized_time(record["started_at"]) &&
|
|
327
|
+
record["actual"].is_a?(Hash)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def legacy_rich?(record)
|
|
331
|
+
return false unless record.is_a?(Hash)
|
|
332
|
+
|
|
333
|
+
meta = record["meta"]
|
|
334
|
+
rich_key_count = legacy_rich_key_count(record)
|
|
335
|
+
meta.is_a?(Hash) &&
|
|
336
|
+
!meta["id"].to_s.empty? &&
|
|
337
|
+
normalized_time(meta["started_at"]) &&
|
|
338
|
+
record["actual"].is_a?(Hash) &&
|
|
339
|
+
(rich_key_count >= 2 || meta["harness"] == "harnex" && rich_key_count.positive?)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def build_row(record, path, line, family)
|
|
343
|
+
id = family == :v2 ? record["id"].to_s : record.dig("meta", "id").to_s
|
|
344
|
+
started_at = normalized_time(family == :v2 ? record["started_at"] : record.dig("meta", "started_at"))
|
|
345
|
+
session_id = family == :v2 ? record["session_id"].to_s : nil
|
|
346
|
+
identity = family == :v2 ? ["v2", session_id, id, started_at] : ["legacy", id, started_at]
|
|
347
|
+
Row.new(
|
|
348
|
+
record: record,
|
|
349
|
+
path: path,
|
|
350
|
+
line: line,
|
|
351
|
+
family: family,
|
|
352
|
+
identity: identity,
|
|
353
|
+
match_identity: [id, started_at],
|
|
354
|
+
sort_key: [started_at, path, line]
|
|
355
|
+
)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def find_match(row, canonical_rows)
|
|
359
|
+
same_family = canonical_rows.find { |candidate| candidate.identity == row.identity }
|
|
360
|
+
return same_family if same_family
|
|
361
|
+
|
|
362
|
+
canonical_rows.find { |candidate| candidate.match_identity == row.match_identity }
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def source_conflicts(rows)
|
|
366
|
+
conflicts = []
|
|
367
|
+
rows.group_by(&:identity).each_value do |group|
|
|
368
|
+
first = group.first
|
|
369
|
+
group.drop(1).each { |row| conflicts << [first, row] unless payload_equal?(first.record, row.record) }
|
|
370
|
+
end
|
|
371
|
+
rows.group_by(&:match_identity).each_value do |group|
|
|
372
|
+
next unless group.map(&:family).uniq.length > 1
|
|
373
|
+
|
|
374
|
+
group.combination(2) do |left, right|
|
|
375
|
+
next if left.family == right.family
|
|
376
|
+
|
|
377
|
+
conflicts << [left, right] unless rows_payload_equal?(left, right)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
conflicts
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def dedupe_source_rows(rows)
|
|
384
|
+
selected = []
|
|
385
|
+
rows.sort_by { |row| [row.sort_key[0], row.match_identity, family_preference(row), row.path, row.line] }.each do |row|
|
|
386
|
+
next if selected.any? { |candidate| candidate.identity == row.identity }
|
|
387
|
+
|
|
388
|
+
match = selected.find { |candidate| candidate.family != row.family && candidate.match_identity == row.match_identity }
|
|
389
|
+
next if match && rows_payload_equal?(match, row)
|
|
390
|
+
|
|
391
|
+
selected << row
|
|
392
|
+
end
|
|
393
|
+
selected
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def dedupe_rows(rows)
|
|
397
|
+
seen = Set.new
|
|
398
|
+
rows.sort_by(&:sort_key).select do |row|
|
|
399
|
+
key = row.identity
|
|
400
|
+
next false if seen.include?(key)
|
|
401
|
+
|
|
402
|
+
seen << key
|
|
403
|
+
true
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def legacy_rich_key_count(record) = (RICH_KEYS & record.keys).length
|
|
408
|
+
|
|
409
|
+
def family_preference(row) = row.family == :legacy ? 0 : 1
|
|
410
|
+
|
|
411
|
+
def append_missing_under_lock(rows)
|
|
412
|
+
FileUtils.mkdir_p(File.dirname(@canonical))
|
|
413
|
+
File.open(@canonical, File::RDWR | File::CREAT, 0o644) do |file|
|
|
414
|
+
file.flock(File::LOCK_EX)
|
|
415
|
+
file.rewind
|
|
416
|
+
existing = file.read
|
|
417
|
+
locked = analyze
|
|
418
|
+
return [0, locked] unless writable?(locked)
|
|
419
|
+
|
|
420
|
+
still_missing = locked[:missing_rows].select { |row| rows.any? { |wanted| wanted.identity == row.identity } }
|
|
421
|
+
payload = still_missing.map { |row| JSON.generate(row.record) }.join("\n")
|
|
422
|
+
payload = "\n#{payload}" unless existing.empty? || existing.end_with?("\n")
|
|
423
|
+
payload = "#{payload}\n" unless payload.empty?
|
|
424
|
+
file.seek(0, IO::SEEK_END)
|
|
425
|
+
file.write(payload)
|
|
426
|
+
file.flush
|
|
427
|
+
[still_missing.length, locked.merge(missing_rows: [])]
|
|
428
|
+
ensure
|
|
429
|
+
file.flock(File::LOCK_UN) unless file.closed?
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def payload_equal?(left, right) = deep_normalize(left) == deep_normalize(right)
|
|
434
|
+
|
|
435
|
+
def rows_payload_equal?(left, right)
|
|
436
|
+
if left.family != right.family
|
|
437
|
+
comparable_rich_payload(left.record) == comparable_rich_payload(right.record)
|
|
438
|
+
else
|
|
439
|
+
payload_equal?(left.record, right.record)
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def comparable_rich_payload(record)
|
|
444
|
+
keys = ["meta", "actual"] + RICH_KEYS
|
|
445
|
+
deep_normalize(record.select { |key, _value| keys.include?(key) })
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def deep_normalize(value)
|
|
449
|
+
case value
|
|
450
|
+
when Hash
|
|
451
|
+
value.keys.sort.to_h do |key|
|
|
452
|
+
normalized =
|
|
453
|
+
if key == "started_at"
|
|
454
|
+
normalized_time(value[key]) || value[key]
|
|
455
|
+
else
|
|
456
|
+
deep_normalize(value[key])
|
|
457
|
+
end
|
|
458
|
+
[key, normalized]
|
|
459
|
+
end
|
|
460
|
+
when Array
|
|
461
|
+
value.map { |item| deep_normalize(item) }
|
|
462
|
+
else
|
|
463
|
+
value
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def normalized_time(value)
|
|
468
|
+
Time.iso8601(value.to_s).utc.iso8601
|
|
469
|
+
rescue ArgumentError
|
|
470
|
+
nil
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def realpath_or_expand(path)
|
|
474
|
+
File.realpath(path)
|
|
475
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
476
|
+
File.expand_path(path)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def identity_label(row) = row.identity.join("|")
|
|
480
|
+
|
|
481
|
+
def reset_diagnostics
|
|
482
|
+
@diagnostics = []
|
|
483
|
+
@diagnostics_truncated = 0
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def diagnostic(message)
|
|
487
|
+
if @diagnostics.length < MAX_DIAGNOSTICS
|
|
488
|
+
@diagnostics << message
|
|
489
|
+
else
|
|
490
|
+
@diagnostics_truncated += 1
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
@@ -10,28 +10,18 @@ module Harnex
|
|
|
10
10
|
root = File.expand_path(repo_root.to_s.empty? ? Dir.pwd : repo_root)
|
|
11
11
|
|
|
12
12
|
latest_summary = nil
|
|
13
|
-
latest_summary_path = nil
|
|
14
13
|
latest_history = nil
|
|
15
14
|
|
|
15
|
+
# The canonical dispatch stream is the only source. Rows a prior
|
|
16
|
+
# release mirrored elsewhere are never read back, so a leftover mirror
|
|
17
|
+
# file on disk cannot resolve status from stale data.
|
|
16
18
|
history_paths(root).each do |path|
|
|
17
19
|
summary, history = scan_dispatch_path(path, normalized_id)
|
|
18
|
-
if newer_summary?(summary, latest_summary)
|
|
19
|
-
latest_summary = summary
|
|
20
|
-
latest_summary_path = path
|
|
21
|
-
end
|
|
20
|
+
latest_summary = summary if newer_summary?(summary, latest_summary)
|
|
22
21
|
latest_history = history if newer_history?(history, latest_history)
|
|
23
22
|
end
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
if summary_path && !summary_path.empty? && File.file?(summary_path)
|
|
27
|
-
summary, = scan_dispatch_path(summary_path, normalized_id)
|
|
28
|
-
if newer_summary?(summary, latest_summary)
|
|
29
|
-
latest_summary = summary
|
|
30
|
-
latest_summary_path = summary_path
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
return build_from_summary(latest_summary, latest_summary_path, root) if latest_summary
|
|
24
|
+
return build_from_summary(latest_summary, root) if latest_summary
|
|
35
25
|
return build_from_history(latest_history, root) if latest_history
|
|
36
26
|
|
|
37
27
|
nil
|
|
@@ -52,7 +42,6 @@ module Harnex
|
|
|
52
42
|
"artifact_report_status" => nil,
|
|
53
43
|
"exit" => nil,
|
|
54
44
|
"exit_code" => nil,
|
|
55
|
-
"summary_out" => nil,
|
|
56
45
|
"started_at" => nil,
|
|
57
46
|
"ended_at" => nil,
|
|
58
47
|
"source" => "none"
|
|
@@ -140,7 +129,7 @@ module Harnex
|
|
|
140
129
|
nil
|
|
141
130
|
end
|
|
142
131
|
|
|
143
|
-
def build_from_summary(record,
|
|
132
|
+
def build_from_summary(record, fallback_repo_root)
|
|
144
133
|
meta = record["meta"] || {}
|
|
145
134
|
actual = record["actual"] || {}
|
|
146
135
|
outcome = record["outcome"] || {}
|
|
@@ -162,10 +151,9 @@ module Harnex
|
|
|
162
151
|
"artifact_report_status" => blank_to_nil(outcome["report_status"]),
|
|
163
152
|
"exit" => blank_to_nil(actual["exit"]),
|
|
164
153
|
"exit_code" => actual["exit_code"],
|
|
165
|
-
"summary_out" => summary_path,
|
|
166
154
|
"started_at" => meta["started_at"],
|
|
167
155
|
"ended_at" => meta["ended_at"],
|
|
168
|
-
"source" => "
|
|
156
|
+
"source" => "dispatch_end"
|
|
169
157
|
}
|
|
170
158
|
end
|
|
171
159
|
|
|
@@ -208,7 +196,6 @@ module Harnex
|
|
|
208
196
|
"artifact_report_status" => nil,
|
|
209
197
|
"exit" => history_exit(status),
|
|
210
198
|
"exit_code" => nil,
|
|
211
|
-
"summary_out" => blank_to_nil(record["summary_out_path"]),
|
|
212
199
|
"started_at" => record["started_at"],
|
|
213
200
|
"ended_at" => record["ended_at"],
|
|
214
201
|
"source" => "dispatch_history"
|
data/lib/harnex/version.rb
CHANGED
data/lib/harnex.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "harnex/pricing"
|
|
|
11
11
|
require_relative "harnex/terminal_status"
|
|
12
12
|
require_relative "harnex/artifact_report"
|
|
13
13
|
require_relative "harnex/context_telemetry"
|
|
14
|
+
require_relative "harnex/telemetry_reconciler"
|
|
14
15
|
require_relative "harnex/orchestration"
|
|
15
16
|
require_relative "harnex/watcher"
|
|
16
17
|
require_relative "harnex/adapters"
|
|
@@ -37,4 +38,5 @@ require_relative "harnex/commands/agents_guide"
|
|
|
37
38
|
require_relative "harnex/commands/doctor"
|
|
38
39
|
require_relative "harnex/commands/orchestration"
|
|
39
40
|
require_relative "harnex/commands/artifact_report"
|
|
41
|
+
require_relative "harnex/commands/telemetry"
|
|
40
42
|
require_relative "harnex/cli"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: harnex
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jikku Jose
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: A local PTY harness that wraps terminal AI agents (Claude, Codex, Pi)
|
|
14
14
|
and adds a control plane for discovery, messaging, and coordination.
|
|
@@ -61,6 +61,7 @@ files:
|
|
|
61
61
|
- lib/harnex/commands/send.rb
|
|
62
62
|
- lib/harnex/commands/status.rb
|
|
63
63
|
- lib/harnex/commands/stop.rb
|
|
64
|
+
- lib/harnex/commands/telemetry.rb
|
|
64
65
|
- lib/harnex/commands/wait.rb
|
|
65
66
|
- lib/harnex/commands/watch.rb
|
|
66
67
|
- lib/harnex/commands/watch_presets.rb
|
|
@@ -77,6 +78,7 @@ files:
|
|
|
77
78
|
- lib/harnex/runtime/message.rb
|
|
78
79
|
- lib/harnex/runtime/session.rb
|
|
79
80
|
- lib/harnex/runtime/session_state.rb
|
|
81
|
+
- lib/harnex/telemetry_reconciler.rb
|
|
80
82
|
- lib/harnex/terminal_status.rb
|
|
81
83
|
- lib/harnex/version.rb
|
|
82
84
|
- lib/harnex/watcher.rb
|