harnex 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -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 +153 -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 +3 -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/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/terminal_status.rb +7 -20
- data/lib/harnex/version.rb +1 -1
- metadata +2 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
require "fileutils"
|
|
3
3
|
require "json"
|
|
4
|
+
require "time"
|
|
4
5
|
|
|
5
6
|
module Harnex
|
|
6
7
|
module ArtifactReport
|
|
@@ -12,6 +13,12 @@ module Harnex
|
|
|
12
13
|
MAX_EVIDENCE_ITEMS = 20
|
|
13
14
|
MAX_STRING_LENGTH = 2_000
|
|
14
15
|
MAX_DIAGNOSTICS = 100
|
|
16
|
+
MAX_CHANGED_PATHS = 200
|
|
17
|
+
RECEIPT_VERSION = 1
|
|
18
|
+
CLAIM_SEVERITIES = %w[P1 P2 P3].freeze
|
|
19
|
+
COMMAND_OBSERVATION_STATUSES = %w[observed unsupported].freeze
|
|
20
|
+
GIT_OBSERVATION_STATUSES = %w[observed unavailable].freeze
|
|
21
|
+
USAGE_STATUSES = %w[observed estimated unsupported missing zero].freeze
|
|
15
22
|
|
|
16
23
|
REPORT_STATUSES = %w[in_progress pass fail blocked unknown].freeze
|
|
17
24
|
VALIDATION_STATUSES = %w[in_progress pass fail not_run unknown].freeze
|
|
@@ -198,6 +205,159 @@ module Harnex
|
|
|
198
205
|
}
|
|
199
206
|
end
|
|
200
207
|
|
|
208
|
+
# Default receipts live outside the checkout so proof generation cannot
|
|
209
|
+
# become part of the Git delta it is recording. An explicit
|
|
210
|
+
# --artifact-report path still wins for callers that need a stable path.
|
|
211
|
+
def default_path(repo_root:, id:, session_id:)
|
|
212
|
+
slug = Harnex.id_key(id)
|
|
213
|
+
slug = Digest::SHA256.hexdigest(id.to_s)[0, 12] if slug.empty?
|
|
214
|
+
File.join(
|
|
215
|
+
Harnex::STATE_DIR,
|
|
216
|
+
"receipts",
|
|
217
|
+
"#{Harnex.repo_key(repo_root)}-#{slug}-#{session_id}.json"
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def claims_path(report_path)
|
|
222
|
+
"#{File.expand_path(report_path.to_s)}.claims.json"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Claims are deliberately advisory. Malformed, stale, or oversized claims
|
|
226
|
+
# are ignored by Session and can never invalidate observed proof.
|
|
227
|
+
def extract_claims(path)
|
|
228
|
+
claims_path = File.expand_path(path.to_s)
|
|
229
|
+
return {} unless File.file?(claims_path)
|
|
230
|
+
return {} if File.size(claims_path) > MAX_BYTES
|
|
231
|
+
|
|
232
|
+
document = JSON.parse(File.read(claims_path, mode: "rb"))
|
|
233
|
+
return {} unless document.is_a?(Hash)
|
|
234
|
+
|
|
235
|
+
source = document["claims"].is_a?(Hash) ? document["claims"] : document
|
|
236
|
+
claims = compact_claims(source)
|
|
237
|
+
|
|
238
|
+
# Compatibility bridge for old worker-authored v1 reports. Their
|
|
239
|
+
# outcome is retained only as an untrusted verdict/summary claim; it no
|
|
240
|
+
# longer participates in completion acceptance.
|
|
241
|
+
if claims.empty? && document["schema"] == SCHEMA
|
|
242
|
+
outcome = document["outcome"]
|
|
243
|
+
if outcome.is_a?(Hash)
|
|
244
|
+
claims = compact_claims(
|
|
245
|
+
"summary" => outcome["summary"],
|
|
246
|
+
"verdict" => outcome["status"]
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
claims
|
|
251
|
+
rescue StandardError
|
|
252
|
+
{}
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def build_observed(
|
|
256
|
+
id:, session_id:, generated_at:, successful:, outcome_status:,
|
|
257
|
+
outcome_summary:, git:, commands:, turn:, usage:, claims:,
|
|
258
|
+
command_observation:
|
|
259
|
+
)
|
|
260
|
+
observed_commands = compact_commands(commands).select do |entry|
|
|
261
|
+
non_empty_string?(entry["cmd"]) && entry["exit_code"].is_a?(Integer)
|
|
262
|
+
end
|
|
263
|
+
git_payload = compact_observed_git(git)
|
|
264
|
+
turn_payload = compact_observed_turn(turn)
|
|
265
|
+
usage_payload = compact_observed_usage(usage)
|
|
266
|
+
claims_payload = compact_claims(claims)
|
|
267
|
+
validation_status = if observed_commands.empty?
|
|
268
|
+
"not_run"
|
|
269
|
+
elsif observed_commands.all? { |entry| entry["exit_code"].zero? }
|
|
270
|
+
"pass"
|
|
271
|
+
else
|
|
272
|
+
"fail"
|
|
273
|
+
end
|
|
274
|
+
start_sha = git_payload["start_sha"].to_s
|
|
275
|
+
end_sha = git_payload["end_sha"].to_s
|
|
276
|
+
commit_sha = end_sha unless start_sha.empty? || end_sha.empty? || start_sha == end_sha
|
|
277
|
+
|
|
278
|
+
report = {
|
|
279
|
+
"schema" => SCHEMA,
|
|
280
|
+
"status" => successful ? "pass" : "fail",
|
|
281
|
+
"canonical_artifacts" => [],
|
|
282
|
+
"outcome" => {
|
|
283
|
+
"status" => outcome_status,
|
|
284
|
+
"summary" => bounded_string(outcome_summary),
|
|
285
|
+
"commit_sha" => commit_sha
|
|
286
|
+
}.compact,
|
|
287
|
+
"validation" => {
|
|
288
|
+
"status" => validation_status,
|
|
289
|
+
"commands" => observed_commands,
|
|
290
|
+
"final_reported" => true
|
|
291
|
+
},
|
|
292
|
+
"artifacts" => [],
|
|
293
|
+
"receipt" => {
|
|
294
|
+
"version" => RECEIPT_VERSION,
|
|
295
|
+
"author" => "harnex",
|
|
296
|
+
"generated_at" => generated_at.respond_to?(:iso8601) ? generated_at.iso8601 : generated_at.to_s,
|
|
297
|
+
"id" => bounded_string(id),
|
|
298
|
+
"session_id" => bounded_string(session_id)
|
|
299
|
+
},
|
|
300
|
+
"observed" => {
|
|
301
|
+
"git" => git_payload,
|
|
302
|
+
"commands" => observed_commands,
|
|
303
|
+
"command_observation" => command_observation.to_s,
|
|
304
|
+
"turn" => turn_payload,
|
|
305
|
+
"usage" => usage_payload
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
report["claims"] = claims_payload unless claims_payload.empty?
|
|
309
|
+
fit_observed_report!(report)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def fit_observed_report!(report)
|
|
313
|
+
loop do
|
|
314
|
+
return report if JSON.pretty_generate(report).bytesize + 1 <= MAX_BYTES
|
|
315
|
+
|
|
316
|
+
commands = report.dig("observed", "commands")
|
|
317
|
+
if commands.is_a?(Array) && !commands.empty?
|
|
318
|
+
commands.pop
|
|
319
|
+
report.dig("observed")["commands_truncated"] = true
|
|
320
|
+
next
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
paths = report.dig("observed", "git", "changed_paths")
|
|
324
|
+
if paths.is_a?(Array) && !paths.empty?
|
|
325
|
+
paths.pop
|
|
326
|
+
report.dig("observed", "git")["changed_paths_truncated"] = true
|
|
327
|
+
next
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
raise ArgumentError, "generated artifact receipt exceeds the #{MAX_BYTES}-byte limit"
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def write_observed(path, **attributes)
|
|
335
|
+
report_path = File.expand_path(path.to_s)
|
|
336
|
+
report = build_observed(**attributes)
|
|
337
|
+
diagnostics = validate_document(report)
|
|
338
|
+
unless diagnostics.empty?
|
|
339
|
+
raise ArgumentError, "generated artifact receipt is invalid: #{diagnostics.first.fetch('path')}"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
FileUtils.mkdir_p(File.dirname(report_path))
|
|
343
|
+
temporary_path = "#{report_path}.tmp.#{Process.pid}.#{Thread.current.object_id}"
|
|
344
|
+
File.open(temporary_path, "wb", 0o644) do |file|
|
|
345
|
+
file.write(JSON.pretty_generate(report))
|
|
346
|
+
file.write("\n")
|
|
347
|
+
file.flush
|
|
348
|
+
file.fsync
|
|
349
|
+
end
|
|
350
|
+
File.rename(temporary_path, report_path)
|
|
351
|
+
|
|
352
|
+
result = validate(report_path, final: !!attributes[:successful])
|
|
353
|
+
if attributes[:successful] && !accepted_final?(result)
|
|
354
|
+
raise ArgumentError, "generated artifact receipt did not satisfy final validation"
|
|
355
|
+
end
|
|
356
|
+
result
|
|
357
|
+
ensure
|
|
358
|
+
FileUtils.rm_f(temporary_path) if defined?(temporary_path) && temporary_path
|
|
359
|
+
end
|
|
360
|
+
|
|
201
361
|
def accepted_final?(result)
|
|
202
362
|
result.ok && ACCEPTED_OUTCOME_STATUSES.include?(outcome_status(result))
|
|
203
363
|
end
|
|
@@ -239,6 +399,10 @@ module Harnex
|
|
|
239
399
|
validate_outcome(report["outcome"], diagnostics) if report.key?("outcome")
|
|
240
400
|
validate_validation(report["validation"], diagnostics) if report.key?("validation")
|
|
241
401
|
validate_artifacts(report["artifacts"], diagnostics) if report.key?("artifacts")
|
|
402
|
+
validate_receipt(report["receipt"], diagnostics) if report.key?("receipt")
|
|
403
|
+
validate_observed(report["observed"], diagnostics) if report.key?("observed")
|
|
404
|
+
# `claims` is intentionally not part of validity. Session emits only a
|
|
405
|
+
# bounded sanitized subset, while malformed worker claims are ignored.
|
|
242
406
|
validate_final_contract(report, diagnostics) if final
|
|
243
407
|
diagnostics.first(MAX_DIAGNOSTICS)
|
|
244
408
|
end
|
|
@@ -306,21 +470,21 @@ module Harnex
|
|
|
306
470
|
end
|
|
307
471
|
end
|
|
308
472
|
|
|
309
|
-
def validate_commands(value, diagnostics)
|
|
473
|
+
def validate_commands(value, diagnostics, base_path: "$.validation.commands")
|
|
310
474
|
unless value.is_a?(Array)
|
|
311
|
-
diagnostics << diagnostic("array_required",
|
|
475
|
+
diagnostics << diagnostic("array_required", base_path, "#{base_path.delete_prefix('$.')} must be an array")
|
|
312
476
|
return
|
|
313
477
|
end
|
|
314
478
|
if value.length > MAX_COMMANDS
|
|
315
479
|
diagnostics << diagnostic(
|
|
316
480
|
"too_many_items",
|
|
317
|
-
|
|
318
|
-
"
|
|
481
|
+
base_path,
|
|
482
|
+
"#{base_path.delete_prefix('$.')} may contain at most #{MAX_COMMANDS} entries"
|
|
319
483
|
)
|
|
320
484
|
end
|
|
321
485
|
|
|
322
486
|
value.first(MAX_COMMANDS).each_with_index do |entry, index|
|
|
323
|
-
path = "
|
|
487
|
+
path = "#{base_path}[#{index}]"
|
|
324
488
|
unless entry.is_a?(Hash)
|
|
325
489
|
diagnostics << diagnostic("object_required", path, "validation command must be an object")
|
|
326
490
|
next
|
|
@@ -386,7 +550,119 @@ module Harnex
|
|
|
386
550
|
end
|
|
387
551
|
end
|
|
388
552
|
|
|
553
|
+
def validate_receipt(value, diagnostics)
|
|
554
|
+
unless value.is_a?(Hash)
|
|
555
|
+
diagnostics << diagnostic("object_required", "$.receipt", "receipt must be an object")
|
|
556
|
+
return
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
unless value["version"] == RECEIPT_VERSION
|
|
560
|
+
diagnostics << diagnostic("receipt_version", "$.receipt.version", "receipt.version must equal #{RECEIPT_VERSION}")
|
|
561
|
+
end
|
|
562
|
+
unless value["author"] == "harnex"
|
|
563
|
+
diagnostics << diagnostic("receipt_author", "$.receipt.author", "receipt.author must equal harnex")
|
|
564
|
+
end
|
|
565
|
+
validate_required_string(value, "generated_at", "$.receipt.generated_at", diagnostics)
|
|
566
|
+
begin
|
|
567
|
+
Time.iso8601(value["generated_at"].to_s)
|
|
568
|
+
rescue ArgumentError
|
|
569
|
+
diagnostics << diagnostic("timestamp_required", "$.receipt.generated_at", "receipt.generated_at must be ISO 8601")
|
|
570
|
+
end
|
|
571
|
+
validate_required_string(value, "id", "$.receipt.id", diagnostics)
|
|
572
|
+
validate_required_string(value, "session_id", "$.receipt.session_id", diagnostics)
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def validate_observed(value, diagnostics)
|
|
576
|
+
unless value.is_a?(Hash)
|
|
577
|
+
diagnostics << diagnostic("object_required", "$.observed", "observed must be an object")
|
|
578
|
+
return
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
git = value["git"]
|
|
582
|
+
unless git.is_a?(Hash)
|
|
583
|
+
diagnostics << diagnostic("object_required", "$.observed.git", "observed.git must be an object")
|
|
584
|
+
else
|
|
585
|
+
unless GIT_OBSERVATION_STATUSES.include?(git["status"])
|
|
586
|
+
diagnostics << diagnostic("enum", "$.observed.git.status", "observed.git.status must be observed or unavailable")
|
|
587
|
+
end
|
|
588
|
+
%w[start_sha end_sha branch].each do |key|
|
|
589
|
+
validate_optional_string(git, key, "$.observed.git.#{key}", diagnostics)
|
|
590
|
+
end
|
|
591
|
+
if git["status"] == "observed"
|
|
592
|
+
%w[start_sha end_sha].each do |key|
|
|
593
|
+
unless git[key].is_a?(String) && git[key].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
|
|
594
|
+
diagnostics << diagnostic("git_sha", "$.observed.git.#{key}", "observed.git.#{key} must be a full hexadecimal Git SHA")
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
validate_string_array_value(
|
|
599
|
+
git["changed_paths"],
|
|
600
|
+
"$.observed.git.changed_paths",
|
|
601
|
+
max_items: MAX_CHANGED_PATHS,
|
|
602
|
+
diagnostics: diagnostics,
|
|
603
|
+
required: true
|
|
604
|
+
) if git["status"] == "observed" || git.key?("changed_paths")
|
|
605
|
+
%w[loc_added loc_removed files_changed commits].each do |key|
|
|
606
|
+
if git["status"] == "observed" && !git.key?(key)
|
|
607
|
+
diagnostics << diagnostic("required", "$.observed.git.#{key}", "observed.git.#{key} is required when Git is observed")
|
|
608
|
+
next
|
|
609
|
+
end
|
|
610
|
+
next unless git.key?(key)
|
|
611
|
+
next if git[key].is_a?(Integer) && git[key] >= 0
|
|
612
|
+
|
|
613
|
+
diagnostics << diagnostic("integer_required", "$.observed.git.#{key}", "observed.git.#{key} must be a non-negative integer")
|
|
614
|
+
end
|
|
615
|
+
%w[start_dirty end_dirty worktree_changed changed_paths_truncated].each do |key|
|
|
616
|
+
next unless git.key?(key)
|
|
617
|
+
next if boolean?(git[key])
|
|
618
|
+
|
|
619
|
+
diagnostics << diagnostic("boolean_required", "$.observed.git.#{key}", "observed.git.#{key} must be true or false")
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
validate_commands(value["commands"], diagnostics, base_path: "$.observed.commands")
|
|
624
|
+
if value.key?("commands_truncated") && !boolean?(value["commands_truncated"])
|
|
625
|
+
diagnostics << diagnostic("boolean_required", "$.observed.commands_truncated", "observed.commands_truncated must be true or false")
|
|
626
|
+
end
|
|
627
|
+
unless COMMAND_OBSERVATION_STATUSES.include?(value["command_observation"])
|
|
628
|
+
diagnostics << diagnostic(
|
|
629
|
+
"enum",
|
|
630
|
+
"$.observed.command_observation",
|
|
631
|
+
"observed.command_observation must be observed or unsupported"
|
|
632
|
+
)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
turn = value["turn"]
|
|
636
|
+
unless turn.is_a?(Hash)
|
|
637
|
+
diagnostics << diagnostic("object_required", "$.observed.turn", "observed.turn must be an object")
|
|
638
|
+
else
|
|
639
|
+
validate_required_string(turn, "status", "$.observed.turn.status", diagnostics)
|
|
640
|
+
%w[task_complete task_failed accepted].each do |key|
|
|
641
|
+
next if boolean?(turn[key])
|
|
642
|
+
|
|
643
|
+
diagnostics << diagnostic("boolean_required", "$.observed.turn.#{key}", "observed.turn.#{key} must be true or false")
|
|
644
|
+
end
|
|
645
|
+
validate_optional_string(turn, "outcome_class", "$.observed.turn.outcome_class", diagnostics)
|
|
646
|
+
if turn.key?("exit_code") && !turn["exit_code"].is_a?(Integer)
|
|
647
|
+
diagnostics << diagnostic("integer_required", "$.observed.turn.exit_code", "observed.turn.exit_code must be an integer")
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
unless value["usage"].is_a?(Hash)
|
|
652
|
+
diagnostics << diagnostic("object_required", "$.observed.usage", "observed.usage must be an object")
|
|
653
|
+
else
|
|
654
|
+
unless USAGE_STATUSES.include?(value.dig("usage", "status"))
|
|
655
|
+
diagnostics << diagnostic("enum", "$.observed.usage.status", "observed.usage.status must be observed, estimated, unsupported, missing, or zero")
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
|
|
389
660
|
def validate_final_contract(report, diagnostics)
|
|
661
|
+
if harness_receipt?(report)
|
|
662
|
+
validate_observed_final_contract(report, diagnostics)
|
|
663
|
+
return
|
|
664
|
+
end
|
|
665
|
+
|
|
390
666
|
unless report["status"] == "pass"
|
|
391
667
|
diagnostics << diagnostic("final_status", "$.status", "final report status must be pass")
|
|
392
668
|
end
|
|
@@ -452,6 +728,55 @@ module Harnex
|
|
|
452
728
|
end
|
|
453
729
|
end
|
|
454
730
|
|
|
731
|
+
def harness_receipt?(report)
|
|
732
|
+
report.dig("receipt", "author") == "harnex"
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# Harness receipts prove that the receipt itself is complete and that
|
|
736
|
+
# Harnex accepted the observed terminal state. Individual command failures
|
|
737
|
+
# remain factual telemetry: they are not treated as worker-authored gates,
|
|
738
|
+
# because exploratory failures can precede a successful final turn.
|
|
739
|
+
def validate_observed_final_contract(report, diagnostics)
|
|
740
|
+
unless report["status"] == "pass"
|
|
741
|
+
diagnostics << diagnostic("final_status", "$.status", "final receipt status must be pass")
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
outcome = report["outcome"]
|
|
745
|
+
unless outcome.is_a?(Hash) && ACCEPTED_OUTCOME_STATUSES.include?(outcome["status"])
|
|
746
|
+
diagnostics << diagnostic("outcome_not_accepted", "$.outcome.status", "final receipt outcome must be accepted or no_change")
|
|
747
|
+
end
|
|
748
|
+
unless outcome.is_a?(Hash) && non_empty_string?(outcome["summary"])
|
|
749
|
+
diagnostics << diagnostic("required", "$.outcome.summary", "final receipt requires a non-empty summary")
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
validation = report["validation"]
|
|
753
|
+
unless validation.is_a?(Hash) && validation["final_reported"] == true
|
|
754
|
+
diagnostics << diagnostic("final_reported", "$.validation.final_reported", "final receipt requires validation.final_reported=true")
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
turn = report.dig("observed", "turn")
|
|
758
|
+
unless turn.is_a?(Hash) && turn["accepted"] == true
|
|
759
|
+
diagnostics << diagnostic("observed_not_accepted", "$.observed.turn.accepted", "final receipt requires an accepted observed turn")
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
return unless outcome.is_a?(Hash) && outcome["status"] == "no_change"
|
|
763
|
+
|
|
764
|
+
git = report.dig("observed", "git")
|
|
765
|
+
no_change = git.is_a?(Hash) &&
|
|
766
|
+
git["status"] == "observed" &&
|
|
767
|
+
git["commits"].to_i.zero? &&
|
|
768
|
+
Array(git["changed_paths"]).empty? &&
|
|
769
|
+
!git["start_sha"].to_s.empty? &&
|
|
770
|
+
git["start_sha"].to_s == git["end_sha"].to_s
|
|
771
|
+
unless no_change
|
|
772
|
+
diagnostics << diagnostic(
|
|
773
|
+
"no_change_unobserved",
|
|
774
|
+
"$.observed.git",
|
|
775
|
+
"no_change requires an observed zero-delta Git state"
|
|
776
|
+
)
|
|
777
|
+
end
|
|
778
|
+
end
|
|
779
|
+
|
|
455
780
|
def validate_string_array_field(report, key, max_items:, diagnostics:)
|
|
456
781
|
return unless report.key?(key)
|
|
457
782
|
|
|
@@ -508,6 +833,9 @@ module Harnex
|
|
|
508
833
|
artifacts = compact_artifacts(report["artifacts"])
|
|
509
834
|
validation = compact_validation(report["validation"])
|
|
510
835
|
outcome = compact_outcome(report["outcome"])
|
|
836
|
+
receipt = compact_receipt(report["receipt"])
|
|
837
|
+
observed = compact_observed(report["observed"])
|
|
838
|
+
claims = compact_claims(report["claims"])
|
|
511
839
|
payload = {
|
|
512
840
|
"artifact_report" => metadata(
|
|
513
841
|
path,
|
|
@@ -518,12 +846,16 @@ module Harnex
|
|
|
518
846
|
).merge(
|
|
519
847
|
"report_status" => bounded_string_or_nil(report["status"]),
|
|
520
848
|
"canonical_artifacts" => string_array(report["canonical_artifacts"], max_items: MAX_CANONICAL_ARTIFACTS),
|
|
521
|
-
"artifact_count" => artifacts.length
|
|
849
|
+
"artifact_count" => artifacts.length,
|
|
850
|
+
"author" => receipt&.dig("author")
|
|
522
851
|
)
|
|
523
852
|
}
|
|
524
853
|
payload["validation"] = validation if validation
|
|
525
854
|
payload["artifacts"] = artifacts unless artifacts.empty?
|
|
526
855
|
payload["outcome"] = outcome if outcome
|
|
856
|
+
payload["receipt"] = receipt if receipt
|
|
857
|
+
payload["observed"] = observed if observed
|
|
858
|
+
payload["claims"] = claims unless claims.empty?
|
|
527
859
|
payload
|
|
528
860
|
end
|
|
529
861
|
|
|
@@ -555,6 +887,107 @@ module Harnex
|
|
|
555
887
|
}
|
|
556
888
|
end
|
|
557
889
|
|
|
890
|
+
def compact_receipt(value)
|
|
891
|
+
return nil unless value.is_a?(Hash)
|
|
892
|
+
|
|
893
|
+
payload = {
|
|
894
|
+
"version" => integer_or_nil(value["version"]),
|
|
895
|
+
"author" => bounded_string_or_nil(value["author"]),
|
|
896
|
+
"generated_at" => bounded_string_or_nil(value["generated_at"]),
|
|
897
|
+
"id" => bounded_string_or_nil(value["id"]),
|
|
898
|
+
"session_id" => bounded_string_or_nil(value["session_id"])
|
|
899
|
+
}
|
|
900
|
+
payload.delete_if { |_key, item| item.nil? }
|
|
901
|
+
payload.empty? ? nil : payload
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
def compact_observed(value)
|
|
905
|
+
return nil unless value.is_a?(Hash)
|
|
906
|
+
|
|
907
|
+
payload = {
|
|
908
|
+
"git" => compact_observed_git(value["git"]),
|
|
909
|
+
"commands" => compact_commands(value["commands"]),
|
|
910
|
+
"commands_truncated" => boolean_or_nil(value["commands_truncated"]),
|
|
911
|
+
"command_observation" => bounded_string_or_nil(value["command_observation"]),
|
|
912
|
+
"turn" => compact_observed_turn(value["turn"]),
|
|
913
|
+
"usage" => compact_observed_usage(value["usage"])
|
|
914
|
+
}
|
|
915
|
+
payload.delete_if { |_key, item| item.nil? }
|
|
916
|
+
payload.empty? ? nil : payload
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
def compact_observed_git(value)
|
|
920
|
+
value = {} unless value.is_a?(Hash)
|
|
921
|
+
status = hash_value(value, "status").to_s
|
|
922
|
+
status = "observed" if status.empty? && !hash_value(value, "start_sha").to_s.empty?
|
|
923
|
+
status = "unavailable" unless GIT_OBSERVATION_STATUSES.include?(status)
|
|
924
|
+
payload = {
|
|
925
|
+
"status" => status,
|
|
926
|
+
"start_sha" => bounded_string_or_nil(hash_value(value, "start_sha")),
|
|
927
|
+
"end_sha" => bounded_string_or_nil(hash_value(value, "end_sha")),
|
|
928
|
+
"branch" => bounded_string_or_nil(hash_value(value, "branch")),
|
|
929
|
+
"changed_paths" => string_array(hash_value(value, "changed_paths"), max_items: MAX_CHANGED_PATHS),
|
|
930
|
+
"loc_added" => non_negative_integer_or_nil(hash_value(value, "loc_added")),
|
|
931
|
+
"loc_removed" => non_negative_integer_or_nil(hash_value(value, "loc_removed")),
|
|
932
|
+
"files_changed" => non_negative_integer_or_nil(hash_value(value, "files_changed")),
|
|
933
|
+
"commits" => non_negative_integer_or_nil(hash_value(value, "commits")),
|
|
934
|
+
"start_dirty" => boolean_or_nil(hash_value(value, "start_dirty")),
|
|
935
|
+
"end_dirty" => boolean_or_nil(hash_value(value, "end_dirty")),
|
|
936
|
+
"worktree_changed" => boolean_or_nil(hash_value(value, "worktree_changed")),
|
|
937
|
+
"changed_paths_truncated" => boolean_or_nil(hash_value(value, "changed_paths_truncated"))
|
|
938
|
+
}
|
|
939
|
+
payload.delete_if { |key, item| item.nil? || (key == "changed_paths" && status == "unavailable") }
|
|
940
|
+
payload
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
def compact_observed_turn(value)
|
|
944
|
+
value = {} unless value.is_a?(Hash)
|
|
945
|
+
payload = {
|
|
946
|
+
"status" => bounded_string_or_nil(hash_value(value, "status")),
|
|
947
|
+
"outcome_class" => bounded_string_or_nil(hash_value(value, "outcome_class")),
|
|
948
|
+
"task_complete" => !!hash_value(value, "task_complete"),
|
|
949
|
+
"task_failed" => !!hash_value(value, "task_failed"),
|
|
950
|
+
"accepted" => !!hash_value(value, "accepted"),
|
|
951
|
+
"exit_code" => integer_or_nil(hash_value(value, "exit_code")),
|
|
952
|
+
"signal" => integer_or_nil(hash_value(value, "signal"))
|
|
953
|
+
}
|
|
954
|
+
payload.delete_if { |_key, item| item.nil? }
|
|
955
|
+
payload
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
def compact_observed_usage(value)
|
|
959
|
+
value = {} unless value.is_a?(Hash)
|
|
960
|
+
payload = {}
|
|
961
|
+
%w[status cost_source cost_price_as_of].each do |key|
|
|
962
|
+
payload[key] = bounded_string_or_nil(hash_value(value, key))
|
|
963
|
+
end
|
|
964
|
+
payload["cost_usd"] = finite_float_or_nil(hash_value(value, "cost_usd"))
|
|
965
|
+
%w[input_tokens output_tokens cached_input_tokens reasoning_tokens total_tokens].each do |key|
|
|
966
|
+
payload[key] = non_negative_integer_or_nil(hash_value(value, key))
|
|
967
|
+
end
|
|
968
|
+
payload.delete_if { |_key, item| item.nil? }
|
|
969
|
+
payload
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
def compact_claims(value)
|
|
973
|
+
return {} unless value.is_a?(Hash)
|
|
974
|
+
|
|
975
|
+
payload = {
|
|
976
|
+
"summary" => bounded_string_or_nil(hash_value(value, "summary")),
|
|
977
|
+
"verdict" => bounded_string_or_nil(hash_value(value, "verdict"))
|
|
978
|
+
}
|
|
979
|
+
findings = hash_value(value, "findings")
|
|
980
|
+
if findings.is_a?(Hash)
|
|
981
|
+
counts = CLAIM_SEVERITIES.each_with_object({}) do |severity, result|
|
|
982
|
+
count = non_negative_integer_or_nil(hash_value(findings, severity))
|
|
983
|
+
result[severity] = count unless count.nil?
|
|
984
|
+
end
|
|
985
|
+
payload["findings"] = counts unless counts.empty?
|
|
986
|
+
end
|
|
987
|
+
payload.delete_if { |_key, item| item.nil? || item == "" }
|
|
988
|
+
payload
|
|
989
|
+
end
|
|
990
|
+
|
|
558
991
|
def compact_validation(value)
|
|
559
992
|
return nil unless value.is_a?(Hash)
|
|
560
993
|
|
|
@@ -661,6 +1094,18 @@ module Harnex
|
|
|
661
1094
|
nil
|
|
662
1095
|
end
|
|
663
1096
|
|
|
1097
|
+
def non_negative_integer_or_nil(value)
|
|
1098
|
+
integer = integer_or_nil(value)
|
|
1099
|
+
integer if integer && integer >= 0
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
def hash_value(hash, key)
|
|
1103
|
+
return nil unless hash.is_a?(Hash)
|
|
1104
|
+
return hash[key] if hash.key?(key)
|
|
1105
|
+
|
|
1106
|
+
hash[key.to_sym]
|
|
1107
|
+
end
|
|
1108
|
+
|
|
664
1109
|
def finite_float_or_nil(value)
|
|
665
1110
|
return nil if value.nil?
|
|
666
1111
|
|
|
@@ -682,6 +1127,10 @@ module Harnex
|
|
|
682
1127
|
value.is_a?(String) && !value.empty? && value.length <= MAX_STRING_LENGTH
|
|
683
1128
|
end
|
|
684
1129
|
|
|
1130
|
+
def boolean_or_nil(value)
|
|
1131
|
+
value if boolean?(value)
|
|
1132
|
+
end
|
|
1133
|
+
|
|
685
1134
|
def boolean?(value)
|
|
686
1135
|
value == true || value == false
|
|
687
1136
|
end
|
data/lib/harnex/cli.rb
CHANGED
|
@@ -134,7 +134,7 @@ module Harnex
|
|
|
134
134
|
orchestration
|
|
135
135
|
Emit external primary samples and report orchestration tax
|
|
136
136
|
artifact-report
|
|
137
|
-
|
|
137
|
+
Validate harness-authored receipts or initialize legacy v1 documents
|
|
138
138
|
help Show command help
|
|
139
139
|
|
|
140
140
|
New to harnex? Start with: harnex guide
|
|
@@ -157,8 +157,8 @@ module Harnex
|
|
|
157
157
|
harnex agents-guide dispatch
|
|
158
158
|
harnex doctor
|
|
159
159
|
harnex orchestration report --dispatch .harnex/dispatch.jsonl --run-id queue-005 --json
|
|
160
|
-
harnex artifact-report
|
|
161
|
-
harnex artifact-report
|
|
160
|
+
harnex artifact-report validate /path/from-dispatch-row.json --final
|
|
161
|
+
harnex artifact-report init .harnex/reports/legacy-v1.json
|
|
162
162
|
harnex send --id main --message "Summarize current progress."
|
|
163
163
|
TEXT
|
|
164
164
|
end
|
|
@@ -10,18 +10,19 @@ module Harnex
|
|
|
10
10
|
#{program_name} validate PATH [--final]
|
|
11
11
|
|
|
12
12
|
Commands:
|
|
13
|
-
init Write a
|
|
14
|
-
validate Validate
|
|
13
|
+
init Write a legacy/manual harnex.artifact_report.v1 skeleton
|
|
14
|
+
validate Validate a harness receipt or legacy document without printing data
|
|
15
15
|
|
|
16
16
|
Options:
|
|
17
|
-
--final Require an accepted/no_change final
|
|
18
|
-
|
|
17
|
+
--final Require an accepted/no_change final harness receipt, or the
|
|
18
|
+
legacy passing-proof contract
|
|
19
19
|
--force Replace an existing file during init
|
|
20
20
|
-h, --help Show this help
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
Normal `harnex run` sessions generate their own receipt; workers do not
|
|
23
|
+
need `init`. Both commands print machine-readable JSON. `validate` exits
|
|
24
|
+
0 only when the requested contract is satisfied; diagnostics contain
|
|
25
|
+
field paths and shape errors, never report payloads or transcripts.
|
|
25
26
|
TEXT
|
|
26
27
|
end
|
|
27
28
|
|
|
@@ -16,7 +16,7 @@ module Harnex
|
|
|
16
16
|
|
|
17
17
|
Options:
|
|
18
18
|
--sweep Include a read-only report of harnex/tmux session drift
|
|
19
|
-
--prune Apply bounded harnex events/output retention pruning
|
|
19
|
+
--prune Apply bounded harnex events/output/receipt retention pruning
|
|
20
20
|
--dry-run Preview --prune candidates without deleting
|
|
21
21
|
-h, --help Show this help
|
|
22
22
|
|