harnex 0.10.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 +22 -0
- data/docs/dispatch-telemetry.md +36 -0
- data/lib/harnex/cli.rb +8 -0
- data/lib/harnex/commands/telemetry.rb +110 -0
- data/lib/harnex/telemetry_reconciler.rb +494 -0
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 869ca6ae88939347c40b89586d058fcb64ea2785981155afebcfcb8ffcb7bf4f
|
|
4
|
+
data.tar.gz: e66e87cc0839ec329a394c9e17ba784e690d094d6ac639232dbc85638c11f9dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6da098e592bd3937ceb378f8cd3c654675c213d3d5c79606a206eb5ddb043dba3e38bc3f05d39e9562add5f2779bd53981ca5a7ba1c17e85e1b9f837f3e59cc3
|
|
7
|
+
data.tar.gz: 63a34a367fd56167001f530aad631e8f8b17739fd7f209a949847245a68b66b79cef3664cf657918b3fea07c567482d738dbe16aa2773d1f5acd2ccda2a53318
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.10.1] - 2026-08-04 | 12:20 AM | IST
|
|
4
|
+
|
|
5
|
+
Patch release candidate: closes the recovery and regression-checking gap left
|
|
6
|
+
after `0.10.0` removed the upstream telemetry mirror. The canonical
|
|
7
|
+
`.harnex/dispatch.jsonl` stream remains the only writer; this release adds
|
|
8
|
+
operator tooling to prove and repair that stream without schema migration.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `harnex telemetry assert-canonical` validates the canonical dispatch stream
|
|
13
|
+
and, when explicit `--source` paths are supplied, fails closed on missing or
|
|
14
|
+
conflicting rich end rows. It is read-only, tolerates mixed-era history
|
|
15
|
+
including open v2 starts and legacy rows, and emits bounded redacted reports.
|
|
16
|
+
- `harnex telemetry reconcile` uses the same analysis, defaults to dry-run, and
|
|
17
|
+
only appends missing rich end rows when `--apply` is passed. It never rewrites,
|
|
18
|
+
deletes, sorts, migrates, cleans source files, or discovers sources
|
|
19
|
+
automatically.
|
|
20
|
+
- Source paths may be files or directories. Directory scans are bounded to
|
|
21
|
+
`.json`/`.jsonl`, skip `.git`, symlinks, and the resolved canonical stream,
|
|
22
|
+
ignore unrelated generic JSON, and treat identity or payload conflicts as
|
|
23
|
+
zero-write failures.
|
|
24
|
+
|
|
3
25
|
## [0.10.0] - 2026-08-03 | 01:44 PM | IST
|
|
4
26
|
|
|
5
27
|
Minor bump, **breaking**: `--summary-out` is removed outright, so the canonical
|
data/docs/dispatch-telemetry.md
CHANGED
|
@@ -66,6 +66,42 @@ harnex orchestration report --dispatch .harnex/dispatch.jsonl --run-id queue-005
|
|
|
66
66
|
|
|
67
67
|
Use `harnex history --json | jq .` for pipelines over the repo-local log.
|
|
68
68
|
|
|
69
|
+
## Canonical assertion and reconciliation
|
|
70
|
+
|
|
71
|
+
`harnex telemetry assert-canonical` is the read-only drift gate for the
|
|
72
|
+
canonical dispatch stream. Without sources it performs structural validation;
|
|
73
|
+
with explicit `--source PATH` inputs it also reports missing or conflicting rich
|
|
74
|
+
end rows and exits non-zero until the canonical stream is clean.
|
|
75
|
+
|
|
76
|
+
`harnex telemetry reconcile` runs the same analysis. It is a dry-run by default;
|
|
77
|
+
only `--apply` appends missing rich end rows, and then only after the canonical
|
|
78
|
+
stream and all source candidates have been parsed and conflict-checked. Apply
|
|
79
|
+
uses one append lock, rechecks under that lock, and is idempotent.
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
harnex telemetry assert-canonical [--canonical PATH | --global] [--source PATH ...] [--json]
|
|
83
|
+
harnex telemetry reconcile [--canonical PATH | --global] --source PATH [--source PATH ...] [--apply] [--json]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The default canonical path is the repo-local `.harnex/dispatch.jsonl`, or the
|
|
87
|
+
global dispatch stream outside a git repo. `--canonical` and `--global` are
|
|
88
|
+
mutually exclusive. Sources are never discovered automatically: each `--source`
|
|
89
|
+
must be a file or directory. Directory scans consider regular `.json` and
|
|
90
|
+
`.jsonl` files, skip `.git`, symlinks, and the resolved canonical path, and
|
|
91
|
+
ignore unrelated JSON that does not match a rich Harnex dispatch end shape.
|
|
92
|
+
|
|
93
|
+
Mixed-era history is valid input. Legacy v1 thin rows, pre-v2 envelope-less rich
|
|
94
|
+
summaries, and v2 start/end rows may coexist. Open v2 starts are tolerated
|
|
95
|
+
because a running or interrupted dispatch may not have an end row yet. Identity
|
|
96
|
+
checks normalize equivalent timestamp offsets, and conflicts fail closed rather
|
|
97
|
+
than choosing a winner.
|
|
98
|
+
|
|
99
|
+
Reports are bounded and redacted: they contain counts, statuses, identities, and
|
|
100
|
+
path:line diagnostics, not raw telemetry payloads, prompts, claims, command
|
|
101
|
+
text, or rich sections. The commands never rewrite, delete, sort, migrate,
|
|
102
|
+
clean source files, reintroduce mirrors, or perform source discovery on their
|
|
103
|
+
own.
|
|
104
|
+
|
|
69
105
|
## Metadata and prediction contract
|
|
70
106
|
|
|
71
107
|
The v2 `dispatch_end` always has `meta`, `predicted`, `actual`, `agent`,
|
data/lib/harnex/cli.rb
CHANGED
|
@@ -41,6 +41,8 @@ module Harnex
|
|
|
41
41
|
OrchestrationCommand.new(@argv.drop(1)).run
|
|
42
42
|
when "artifact-report"
|
|
43
43
|
ArtifactReportCommand.new(@argv.drop(1)).run
|
|
44
|
+
when "telemetry"
|
|
45
|
+
TelemetryCommand.new(@argv.drop(1)).run
|
|
44
46
|
when "help"
|
|
45
47
|
puts help(@argv[1])
|
|
46
48
|
0
|
|
@@ -91,6 +93,8 @@ module Harnex
|
|
|
91
93
|
OrchestrationCommand.usage
|
|
92
94
|
when "artifact-report"
|
|
93
95
|
ArtifactReportCommand.usage
|
|
96
|
+
when "telemetry"
|
|
97
|
+
TelemetryCommand.usage
|
|
94
98
|
else
|
|
95
99
|
usage
|
|
96
100
|
end
|
|
@@ -113,6 +117,7 @@ module Harnex
|
|
|
113
117
|
harnex doctor
|
|
114
118
|
harnex orchestration sample|report [options]
|
|
115
119
|
harnex artifact-report init|validate PATH [options]
|
|
120
|
+
harnex telemetry assert-canonical|reconcile [options]
|
|
116
121
|
harnex help [command]
|
|
117
122
|
|
|
118
123
|
Commands:
|
|
@@ -135,6 +140,8 @@ module Harnex
|
|
|
135
140
|
Emit external primary samples and report orchestration tax
|
|
136
141
|
artifact-report
|
|
137
142
|
Validate harness-authored receipts or initialize legacy v1 documents
|
|
143
|
+
telemetry
|
|
144
|
+
Assert or reconcile canonical dispatch telemetry
|
|
138
145
|
help Show command help
|
|
139
146
|
|
|
140
147
|
New to harnex? Start with: harnex guide
|
|
@@ -159,6 +166,7 @@ module Harnex
|
|
|
159
166
|
harnex orchestration report --dispatch .harnex/dispatch.jsonl --run-id queue-005 --json
|
|
160
167
|
harnex artifact-report validate /path/from-dispatch-row.json --final
|
|
161
168
|
harnex artifact-report init .harnex/reports/legacy-v1.json
|
|
169
|
+
harnex telemetry assert-canonical --canonical .harnex/dispatch.jsonl --json
|
|
162
170
|
harnex send --id main --message "Summarize current progress."
|
|
163
171
|
TEXT
|
|
164
172
|
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "optparse"
|
|
3
|
+
|
|
4
|
+
module Harnex
|
|
5
|
+
class TelemetryCommand
|
|
6
|
+
COMMANDS = %w[assert-canonical reconcile].freeze
|
|
7
|
+
|
|
8
|
+
def self.usage(program_name = "harnex telemetry")
|
|
9
|
+
<<~TEXT
|
|
10
|
+
Usage:
|
|
11
|
+
#{program_name} assert-canonical [--canonical PATH | --global] [--source PATH] [--json]
|
|
12
|
+
#{program_name} reconcile [--canonical PATH | --global] --source PATH [--apply] [--json]
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--canonical PATH Canonical dispatch JSONL path
|
|
16
|
+
--global Use the global harnex dispatch JSONL
|
|
17
|
+
--source PATH Source JSONL file or directory; repeatable
|
|
18
|
+
--apply Append missing records when reconciling
|
|
19
|
+
--json Emit JSON report
|
|
20
|
+
-h, --help Show this help
|
|
21
|
+
TEXT
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(argv)
|
|
25
|
+
@argv = argv.dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run
|
|
29
|
+
command = @argv.shift
|
|
30
|
+
case command
|
|
31
|
+
when nil, "-h", "--help"
|
|
32
|
+
puts self.class.usage
|
|
33
|
+
0
|
|
34
|
+
when *COMMANDS
|
|
35
|
+
options = parse_options(command, @argv)
|
|
36
|
+
if options[:help]
|
|
37
|
+
puts self.class.usage
|
|
38
|
+
return 0
|
|
39
|
+
end
|
|
40
|
+
validate_options!(command, options)
|
|
41
|
+
result = TelemetryReconciler.new(
|
|
42
|
+
command: command,
|
|
43
|
+
canonical: canonical_path(options),
|
|
44
|
+
sources: options[:sources],
|
|
45
|
+
apply: command == "reconcile" && options[:apply]
|
|
46
|
+
).run
|
|
47
|
+
if options[:json]
|
|
48
|
+
puts JSON.generate(result.report)
|
|
49
|
+
else
|
|
50
|
+
puts render_human(result.report)
|
|
51
|
+
end
|
|
52
|
+
result.exitstatus
|
|
53
|
+
else
|
|
54
|
+
raise OptionParser::ParseError, "unknown telemetry subcommand #{command.inspect}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def parse_options(command, argv)
|
|
61
|
+
options = { command: command, sources: [], apply: false, json: false }
|
|
62
|
+
parser(options).parse!(argv)
|
|
63
|
+
raise OptionParser::InvalidArgument, "unexpected arguments: #{argv.join(' ')}" unless argv.empty?
|
|
64
|
+
|
|
65
|
+
options
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parser(options)
|
|
69
|
+
OptionParser.new do |opts|
|
|
70
|
+
opts.banner = "Usage: harnex telemetry #{options[:command]} [options]"
|
|
71
|
+
opts.on("--canonical PATH") { |value| options[:canonical] = value }
|
|
72
|
+
opts.on("--global") { options[:global] = true }
|
|
73
|
+
opts.on("--source PATH") { |value| options[:sources] << value }
|
|
74
|
+
opts.on("--apply") { options[:apply] = true }
|
|
75
|
+
opts.on("--json") { options[:json] = true }
|
|
76
|
+
opts.on("-h", "--help") { options[:help] = true }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_options!(command, options)
|
|
81
|
+
if options[:canonical] && options[:global]
|
|
82
|
+
raise OptionParser::InvalidOption, "--canonical and --global are mutually exclusive"
|
|
83
|
+
end
|
|
84
|
+
if command == "assert-canonical" && options[:apply]
|
|
85
|
+
raise OptionParser::InvalidOption, "--apply is only supported with reconcile"
|
|
86
|
+
end
|
|
87
|
+
return unless command == "reconcile" && options[:sources].empty?
|
|
88
|
+
|
|
89
|
+
raise OptionParser::MissingArgument, "reconcile --source required"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def canonical_path(options)
|
|
93
|
+
return Harnex::DispatchHistory.global_path if options[:global]
|
|
94
|
+
|
|
95
|
+
options[:canonical] || Harnex::DispatchHistory.path_for(Dir.pwd)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def render_human(report)
|
|
99
|
+
lines = [
|
|
100
|
+
"telemetry #{report.fetch(:command)}: #{report.fetch(:status)}",
|
|
101
|
+
"canonical: #{report.fetch(:canonical)}",
|
|
102
|
+
"rows: #{report.fetch(:canonical_rows)} missing=#{report.fetch(:missing)} conflicts=#{report.fetch(:conflicts)} open_starts=#{report.fetch(:open_starts)} appended=#{report.fetch(:appended)}"
|
|
103
|
+
]
|
|
104
|
+
report.fetch(:diagnostics).each { |diagnostic| lines << "diagnostic: #{diagnostic}" }
|
|
105
|
+
truncated = report.fetch(:diagnostics_truncated)
|
|
106
|
+
lines << "diagnostics truncated: #{truncated}" if truncated.to_i.positive?
|
|
107
|
+
lines.join("\n")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -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
|
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,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: harnex
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.10.
|
|
4
|
+
version: 0.10.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jikku Jose
|
|
@@ -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
|