harnex 0.8.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.
@@ -7,7 +7,7 @@ module Harnex
7
7
 
8
8
  def self.usage
9
9
  <<~TEXT
10
- Usage: harnex doctor [--sweep]
10
+ Usage: harnex doctor [--sweep] [--prune [--dry-run]]
11
11
 
12
12
  Runs preflight checks for harnex's adapter dependencies.
13
13
  Currently verifies that Codex CLI is installed and at version
@@ -16,16 +16,21 @@ 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/receipt retention pruning
20
+ --dry-run Preview --prune candidates without deleting
19
21
  -h, --help Show this help
20
22
 
21
23
  Common patterns:
22
24
  harnex doctor
23
25
  harnex doctor --sweep
26
+ harnex doctor --prune --dry-run
27
+ harnex doctor --prune
24
28
  harnex doctor --help
25
29
 
26
30
  Gotchas:
27
31
  doctor validates local adapter prerequisites; it does not start sessions.
28
32
  --sweep is diagnostic only; it does not stop sessions or remove files.
33
+ --dry-run must be paired with --prune.
29
34
  Run it after installing or upgrading Codex CLI.
30
35
  TEXT
31
36
  end
@@ -34,21 +39,26 @@ module Harnex
34
39
  @argv = argv.dup
35
40
  @options = {
36
41
  sweep: false,
42
+ prune: false,
43
+ dry_run: false,
37
44
  help: false
38
45
  }
39
46
  end
40
47
 
41
48
  def run
42
49
  parser.parse!(@argv)
50
+ validate_options!
43
51
  if @options[:help]
44
52
  puts self.class.usage
45
53
  return 0
46
54
  end
47
55
 
48
56
  checks = [check_codex]
57
+ retention = retention_payload
49
58
  summary = {
50
- ok: checks.all? { |c| c[:ok] },
51
- checks: checks
59
+ ok: checks.all? { |c| c[:ok] } && retention.fetch(:ok, true),
60
+ checks: checks,
61
+ retention: retention
52
62
  }
53
63
  summary[:sweep] = sweep_payload if @options[:sweep]
54
64
  puts JSON.generate(summary)
@@ -59,12 +69,36 @@ module Harnex
59
69
 
60
70
  def parser
61
71
  @parser ||= OptionParser.new do |opts|
62
- opts.banner = "Usage: harnex doctor [--sweep]"
72
+ opts.banner = "Usage: harnex doctor [--sweep] [--prune [--dry-run]]"
63
73
  opts.on("--sweep", "Include read-only session drift diagnostics") { @options[:sweep] = true }
74
+ opts.on("--prune", "Apply retention pruning") { @options[:prune] = true }
75
+ opts.on("--dry-run", "Preview --prune candidates without deleting") { @options[:dry_run] = true }
64
76
  opts.on("-h", "--help", "Show help") { @options[:help] = true }
65
77
  end
66
78
  end
67
79
 
80
+ def validate_options!
81
+ return if @options[:help]
82
+ return unless @options[:dry_run] && !@options[:prune]
83
+
84
+ raise OptionParser::InvalidOption, "--dry-run requires --prune"
85
+ end
86
+
87
+ def retention_payload
88
+ repo_root = Harnex.resolve_repo_root(Dir.pwd)
89
+ if @options[:prune]
90
+ Harnex::Retention.prune(
91
+ repo_root: repo_root,
92
+ dry_run: @options[:dry_run],
93
+ force: true
94
+ )
95
+ else
96
+ Harnex::Retention.status(repo_root: repo_root)
97
+ end
98
+ rescue Harnex::Config::ConfigError => e
99
+ { ok: false, error: e.message }
100
+ end
101
+
68
102
  def check_codex
69
103
  result = { name: "codex", required: ">= #{MIN_CODEX_VERSION}" }
70
104
 
@@ -85,6 +85,7 @@ module Harnex
85
85
  # One row per dispatch: start rows completed by an end row are dropped
86
86
  # (the end row carries the outcome); uncompleted start rows surface as
87
87
  # running (pid alive on this host) or interrupted (no end row, pid gone).
88
+ # Rows in neither shape (pre-0.7.3 schemas) are skipped entirely.
88
89
  def derived_records
89
90
  raw = load_records
90
91
  ended = Set.new
@@ -97,10 +98,13 @@ module Harnex
97
98
  end
98
99
 
99
100
  raw.filter_map do |record|
100
- next record unless DispatchHistory.start_record?(record)
101
- next nil if start_completed?(record, ended)
101
+ if DispatchHistory.start_record?(record)
102
+ next nil if start_completed?(record, ended)
102
103
 
103
- derive_live_record(record)
104
+ derive_live_record(record)
105
+ elsif DispatchHistory.end_record?(record)
106
+ record
107
+ end
104
108
  end
105
109
  end
106
110
 
@@ -31,7 +31,7 @@ module Harnex
31
31
 
32
32
  KNOWN_FLAGS = %w[
33
33
  --id --description --detach --tmux --host --port --watch --watch-file
34
- --stall-after --max-resumes --preset --context --meta --summary-out
34
+ --stall-after --max-resumes --preset --context --meta
35
35
  --artifact-report --validation-report --cwd --root --timeout --inbox-ttl
36
36
  --require-artifact-report --require-attribution --auto-stop --fast --legacy-pty
37
37
  --allow-live-parent --help
@@ -39,10 +39,10 @@ module Harnex
39
39
 
40
40
  # Attempt kinds that redo the parent's work; dispatching one while the
41
41
  # parent is still running duplicates work in the same checkout.
42
- LIVE_PARENT_GUARDED_KINDS = %w[retry fix superseding].freeze
42
+ LIVE_PARENT_GUARDED_KINDS = %w[retry fix fallback superseding].freeze
43
43
  VALUE_FLAGS = %w[
44
44
  --id --description --host --port --watch --watch-file --stall-after
45
- --max-resumes --preset --context --meta --summary-out --artifact-report
45
+ --max-resumes --preset --context --meta --artifact-report
46
46
  --validation-report --cwd --root --timeout --inbox-ttl
47
47
  ].concat(TELEMETRY_FLAGS.keys).freeze
48
48
 
@@ -67,14 +67,13 @@ module Harnex
67
67
  --fast (codex only) Use Codex service_tier="fast".
68
68
  Default Codex runs force service_tier="flex".
69
69
  --meta JSON Attach parsed JSON metadata to the started event
70
- --summary-out PATH Append dispatch telemetry summary JSONL to PATH
71
70
  --artifact-report PATH
72
- Worker-written harnex.artifact_report.v1 JSON sidecar to ingest at exit
71
+ Override the harness-authored observed-state receipt path
73
72
  --validation-report PATH
74
- Alias for --artifact-report; also exposed as HARNEX_VALIDATION_REPORT_PATH
73
+ Compatibility alias for --artifact-report
75
74
  --require-artifact-report
76
- Fail closed unless PATH contains accepted final proof;
77
- requires --artifact-report or --validation-report
75
+ Compatibility strict-proof flag; a default harness receipt
76
+ path is used when no explicit path is provided
78
77
  --project-id ID Queue telemetry project id (first-class flags override --meta)
79
78
  --queue-id ID Queue telemetry queue id
80
79
  --entry-id ID Queue telemetry entry id
@@ -87,12 +86,12 @@ module Harnex
87
86
  --model NAME Requested model metadata (also used for structured dispatch)
88
87
  --effort LEVEL Requested reasoning effort metadata (structured dispatch)
89
88
  --parent-dispatch-id ID
90
- Parent dispatch id for retry/fix/review joins
89
+ Parent dispatch id for retry/fix/review/fallback joins
91
90
  --parent-attempt-id ID
92
- Parent attempt id for retry/fix/review joins
91
+ Parent attempt id for retry/fix/review/fallback joins
93
92
  --attempt-kind KIND
94
- initial, retry, fix, review, or superseding (default: initial).
95
- retry requires --parent-dispatch-id so the
93
+ initial, retry, fix, review, fallback, or superseding (default: initial).
94
+ retry/fallback requires --parent-dispatch-id so the
96
95
  duplicate-dispatch guard can verify the parent
97
96
  --allow-live-parent
98
97
  Dispatch even though --parent-dispatch-id names a
@@ -124,8 +123,9 @@ module Harnex
124
123
  Compatibility: `--watch PATH` and `--watch=PATH` still configure file-hook mode.
125
124
  Bare `--watch` enables the babysitter.
126
125
  --auto-stop requires --context. Structured Codex turns only count as
127
- accepted completion after activity, Git delta, or accepted sidecar proof.
128
- --require-artifact-report makes sidecar validation part of the run verdict.
126
+ accepted completion after command/tool activity or a Git delta.
127
+ Every dispatch gets a harness-authored receipt. Workers may write only
128
+ optional claims to HARNEX_ARTIFACT_CLAIMS_PATH; claims never accept work.
129
129
  Explicit --stall-after/--max-resumes values override --preset defaults.
130
130
  CLIs with smart prompt detection: #{Adapters.known.join(', ')}
131
131
  Any other CLI name is launched with generic wrapping.
@@ -136,7 +136,7 @@ module Harnex
136
136
  #{program_name} pi --id pi-i-42 --tmux pi-i-42 --context "Read /tmp/task-impl-42.md" --auto-stop
137
137
  #{program_name} pi --id pi-i-42 --watch --preset impl --context "Read /tmp/task-impl-42.md"
138
138
  #{program_name} codex --cwd /tmp/public-bundle --id eval-001 --context "Read README.md and write OUTPUT.md" --auto-stop
139
- #{program_name} pi --id pi-i-52 --artifact-report .harnex/reports/pi-i-52.json --context "Write proof to $HARNEX_ARTIFACT_REPORT_PATH" --auto-stop
139
+ #{program_name} pi --id pi-r-64 --context 'Optionally write review claims to $HARNEX_ARTIFACT_CLAIMS_PATH' --auto-stop
140
140
  #{program_name} pi --project-id harnex --queue-id queue-005 --entry-id SP-4 --phase implement --intent queue-work --require-attribution --context "Implement SP-4"
141
141
  #{program_name} claude --id cl-r-42 --tmux cl-r-42 --description "Review task 42"
142
142
 
@@ -169,7 +169,6 @@ module Harnex
169
169
  meta: nil,
170
170
  telemetry: {},
171
171
  require_attribution: false,
172
- summary_out: nil,
173
172
  artifact_report: nil,
174
173
  require_artifact_report: false,
175
174
  cwd: nil,
@@ -203,7 +202,7 @@ module Harnex
203
202
  validate_required_attribution!
204
203
 
205
204
  repo_root = resolve_run_root(cli_name, child_args)
206
- @options[:summary_out] = resolve_summary_out(repo_root)
205
+ validate_repo_phase_policy!(repo_root)
207
206
  @options[:artifact_report] = resolve_artifact_report(repo_root)
208
207
  @options[:id] ||= Harnex.generate_id(repo_root)
209
208
  validate_unique_id!(repo_root)
@@ -272,7 +271,6 @@ module Harnex
272
271
  end
273
272
  tmux_cmd << "--require-attribution" if @options[:require_attribution]
274
273
  tmux_cmd << "--allow-live-parent" if @options[:allow_live_parent]
275
- tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
276
274
  tmux_cmd += ["--artifact-report", @options[:artifact_report]] if @options[:artifact_report]
277
275
  tmux_cmd << "--require-artifact-report" if @options[:require_artifact_report]
278
276
  tmux_cmd += ["--cwd", @options[:cwd]] if @options[:cwd]
@@ -307,7 +305,9 @@ module Harnex
307
305
  port: registry["port"],
308
306
  mode: "tmux",
309
307
  window: window_name,
310
- output_log_path: Harnex.output_log_path(repo_root, @options[:id])
308
+ output_log_path: Harnex.output_log_path(repo_root, @options[:id]),
309
+ artifact_report_path: registry["artifact_report_path"],
310
+ artifact_claims_path: registry["artifact_claims_path"]
311
311
  }
312
312
  payload[:description] = @options[:description] if @options[:description]
313
313
  puts JSON.generate(payload)
@@ -346,7 +346,9 @@ module Harnex
346
346
  port: registry["port"],
347
347
  mode: "headless",
348
348
  log: log_path,
349
- output_log_path: Harnex.output_log_path(repo_root, @options[:id])
349
+ output_log_path: Harnex.output_log_path(repo_root, @options[:id]),
350
+ artifact_report_path: registry["artifact_report_path"],
351
+ artifact_claims_path: registry["artifact_claims_path"]
350
352
  }
351
353
  payload[:description] = @options[:description] if @options[:description]
352
354
  puts JSON.generate(payload) if emit_payload
@@ -371,7 +373,7 @@ module Harnex
371
373
  "Use a different --id or stop the existing session first."
372
374
  end
373
375
 
374
- # Duplicate-dispatch guard (issue #62): a retry/fix/superseding attempt
376
+ # Duplicate-dispatch guard (issue #62): a retry/fix/fallback/superseding attempt
375
377
  # whose parent dispatch is still running would duplicate work in the same
376
378
  # checkout. Explicit --parent-dispatch-id only — the implicit HARNEX_ID
377
379
  # lineage of a live spawner must not trip this.
@@ -380,8 +382,8 @@ module Harnex
380
382
  kind = metadata["attempt_kind"].to_s
381
383
  parent_id = metadata["parent_dispatch_id"].to_s.strip
382
384
 
383
- if kind == "retry" && parent_id.empty?
384
- raise "harnex run: --attempt-kind retry requires --parent-dispatch-id " \
385
+ if %w[retry fallback].include?(kind) && parent_id.empty?
386
+ raise "harnex run: --attempt-kind #{kind} requires --parent-dispatch-id " \
385
387
  "so the duplicate-dispatch guard can verify the parent is not still running."
386
388
  end
387
389
 
@@ -398,6 +400,30 @@ module Harnex
398
400
  "or pass --allow-live-parent for intentional parallelism."
399
401
  end
400
402
 
403
+ def validate_repo_phase_policy!(repo_root)
404
+ config = Harnex::Config.load_repo(repo_root)
405
+ Harnex::Config.retention_limits(config: config)
406
+ phase_config = config.phase
407
+ return unless phase_config
408
+
409
+ metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
410
+ phase = metadata["phase"].to_s
411
+ allowlist = phase_config.fetch("allowlist")
412
+ return if allowlist.include?(phase)
413
+
414
+ policy = phase_config.fetch("policy")
415
+ message = "harnex run: meta.phase #{phase.empty? ? '(none)' : phase.inspect} " \
416
+ "is not allowlisted by #{config.path} " \
417
+ "(allowed: #{allowlist.join(', ')}; policy: #{policy})"
418
+ if policy == "warn"
419
+ warn("harnex: warning: #{message}")
420
+ else
421
+ raise OptionParser::InvalidOption, message
422
+ end
423
+ rescue Harnex::Config::ConfigError => e
424
+ raise OptionParser::InvalidOption, "harnex run: invalid config #{e.message}"
425
+ end
426
+
401
427
  def build_session(adapter, repo_root)
402
428
  watch = Harnex.build_watch_config(@options[:watch], repo_root)
403
429
  Session.new(
@@ -410,7 +436,6 @@ module Harnex
410
436
  watch: watch,
411
437
  description: @options[:description],
412
438
  meta: @options[:meta],
413
- summary_out: @options[:summary_out],
414
439
  artifact_report_path: @options[:artifact_report],
415
440
  require_artifact_report: @options[:require_artifact_report],
416
441
  inbox_ttl: @options[:inbox_ttl],
@@ -599,11 +624,6 @@ module Harnex
599
624
  when telemetry_equals_regex
600
625
  flag = "--#{Regexp.last_match(1)}"
601
626
  @options[:telemetry][TELEMETRY_FLAGS.fetch(flag)] = required_option_value(flag, Regexp.last_match(2))
602
- when "--summary-out"
603
- index += 1
604
- @options[:summary_out] = required_option_value(arg, argv[index])
605
- when /\A--summary-out=(.+)\z/
606
- @options[:summary_out] = required_option_value("--summary-out", Regexp.last_match(1))
607
627
  when "--artifact-report", "--validation-report"
608
628
  index += 1
609
629
  @options[:artifact_report] = required_option_value(arg, argv[index])
@@ -695,7 +715,7 @@ module Harnex
695
715
  nil
696
716
  when *VALUE_FLAGS
697
717
  index += 1
698
- when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|summary-out|artifact-report|validation-report|cwd|root|timeout|inbox-ttl)=/
718
+ when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|artifact-report|validation-report|cwd|root|timeout|inbox-ttl)=/
699
719
  nil
700
720
  when telemetry_equals_regex
701
721
  nil
@@ -715,7 +735,7 @@ module Harnex
715
735
  arg == "-h" ||
716
736
  arg.start_with?(
717
737
  "--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
718
- "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=", "--summary-out=",
738
+ "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=",
719
739
  "--artifact-report=", "--validation-report=", "--cwd=", "--root=", "--timeout=", "--inbox-ttl=",
720
740
  *TELEMETRY_EQUALS_PREFIXES
721
741
  )
@@ -746,12 +766,11 @@ module Harnex
746
766
  raise OptionParser::InvalidOption, "harnex run: --auto-stop requires --context"
747
767
  end
748
768
 
769
+ # Retained for compatibility with callers that still pass the strict flag.
770
+ # Session now allocates a harness-owned default receipt path, so an explicit
771
+ # --artifact-report path is no longer required.
749
772
  def validate_required_artifact_report!
750
- return unless @options[:require_artifact_report]
751
- return unless @options[:artifact_report].to_s.strip.empty?
752
-
753
- raise OptionParser::InvalidOption,
754
- "harnex run: --require-artifact-report requires --artifact-report PATH"
773
+ true
755
774
  end
756
775
 
757
776
  def apply_telemetry_options!
@@ -842,13 +861,6 @@ module Harnex
842
861
  raise OptionParser::InvalidOption, "--meta must be valid JSON: #{e.message}"
843
862
  end
844
863
 
845
- def resolve_summary_out(repo_root)
846
- configured = @options[:summary_out]
847
- return Harnex.default_summary_out_path(repo_root) if configured.nil?
848
-
849
- File.expand_path(configured, repo_root)
850
- end
851
-
852
864
  def resolve_artifact_report(repo_root)
853
865
  configured = @options[:artifact_report]
854
866
  return nil if configured.nil?
@@ -127,7 +127,6 @@ module Harnex
127
127
  "work_state" => "running",
128
128
  "exit" => nil,
129
129
  "exit_code" => nil,
130
- "summary_out" => nil,
131
130
  "ended_at" => nil,
132
131
  "source" => "dispatch_start",
133
132
  "degraded" => true,
@@ -150,7 +149,6 @@ module Harnex
150
149
  "work_state" => work_state,
151
150
  "exit" => nil,
152
151
  "exit_code" => nil,
153
- "summary_out" => nil,
154
152
  "ended_at" => nil,
155
153
  "source" => degraded ? "registry" : "live",
156
154
  "degraded" => degraded
@@ -624,7 +624,6 @@ module Harnex
624
624
  artifact_report_status: status["artifact_report_status"],
625
625
  exit: status["exit"],
626
626
  exit_code: status["exit_code"],
627
- summary_out: status["summary_out"],
628
627
  ended_at: status["ended_at"],
629
628
  source: status["source"]
630
629
  }
@@ -0,0 +1,170 @@
1
+ require "json"
2
+
3
+ module Harnex
4
+ module Config
5
+ CONFIG_RELATIVE_PATH = File.join(".harnex", "config.json").freeze
6
+ PHASE_POLICIES = %w[warn reject].freeze
7
+ RETENTION_DIRS = %w[events output receipts].freeze
8
+ RETENTION_FIELDS = %w[max_age_days max_bytes].freeze
9
+ RETENTION_ENV = {
10
+ "events" => {
11
+ "max_age_days" => "HARNEX_EVENTS_MAX_AGE_DAYS",
12
+ "max_bytes" => "HARNEX_EVENTS_MAX_BYTES"
13
+ },
14
+ "output" => {
15
+ "max_age_days" => "HARNEX_OUTPUT_MAX_AGE_DAYS",
16
+ "max_bytes" => "HARNEX_OUTPUT_MAX_BYTES"
17
+ },
18
+ "receipts" => {
19
+ "max_age_days" => "HARNEX_RECEIPTS_MAX_AGE_DAYS",
20
+ "max_bytes" => "HARNEX_RECEIPTS_MAX_BYTES"
21
+ }
22
+ }.freeze
23
+ DEFAULT_RETENTION_LIMITS = RETENTION_DIRS.to_h do |dir|
24
+ [dir, { "max_age_days" => 45, "max_bytes" => 1_073_741_824 }]
25
+ end.freeze
26
+
27
+ ConfigError = Class.new(StandardError)
28
+
29
+ RepoConfig = Struct.new(:root, :path, :data, keyword_init: true) do
30
+ def present?
31
+ !path.nil? && File.file?(path)
32
+ end
33
+
34
+ def phase
35
+ data.is_a?(Hash) ? data["phase"] : nil
36
+ end
37
+
38
+ def retention
39
+ data.is_a?(Hash) ? data["retention"] : nil
40
+ end
41
+ end
42
+
43
+ module_function
44
+
45
+ def load_repo(start_path)
46
+ root = DispatchHistory.find_git_root(start_path)
47
+ return RepoConfig.new(root: nil, path: nil, data: {}) unless root
48
+
49
+ path = File.join(root, CONFIG_RELATIVE_PATH)
50
+ return RepoConfig.new(root: root, path: path, data: {}) unless File.file?(path)
51
+
52
+ parsed = JSON.parse(File.read(path))
53
+ validate_document!(parsed, path)
54
+ RepoConfig.new(root: root, path: path, data: parsed)
55
+ rescue JSON::ParserError => e
56
+ raise ConfigError, "#{path}: malformed JSON: #{e.message}"
57
+ end
58
+
59
+ def validate_document!(document, path)
60
+ unless document.is_a?(Hash)
61
+ raise ConfigError, "#{path}: config must be a JSON object"
62
+ end
63
+
64
+ validate_phase!(document["phase"], path) if document.key?("phase")
65
+ validate_retention!(document["retention"], path) if document.key?("retention")
66
+ end
67
+
68
+ def validate_phase!(phase, path)
69
+ unless phase.is_a?(Hash)
70
+ raise ConfigError, "#{path}: $.phase must be an object"
71
+ end
72
+
73
+ allowlist = phase["allowlist"]
74
+ unless allowlist.is_a?(Array)
75
+ raise ConfigError, "#{path}: $.phase.allowlist must be an array"
76
+ end
77
+
78
+ allowlist.each_with_index do |entry, index|
79
+ next if entry.is_a?(String) && !entry.strip.empty?
80
+
81
+ raise ConfigError,
82
+ "#{path}: $.phase.allowlist[#{index}] must be a non-empty string"
83
+ end
84
+
85
+ policy = phase["policy"]
86
+ unless PHASE_POLICIES.include?(policy)
87
+ raise ConfigError,
88
+ "#{path}: $.phase.policy must be one of #{PHASE_POLICIES.join(', ')}"
89
+ end
90
+ end
91
+
92
+ def retention_limits(start_path = Dir.pwd, env: ENV, config: nil)
93
+ config ||= load_repo(start_path)
94
+ limits = deep_dup_retention_defaults
95
+ retention = config.retention
96
+
97
+ if retention
98
+ RETENTION_DIRS.each do |dir|
99
+ next unless retention.key?(dir)
100
+
101
+ RETENTION_FIELDS.each do |field|
102
+ next unless retention.fetch(dir).key?(field)
103
+
104
+ limits.fetch(dir)[field] = parse_positive_integer!(
105
+ retention.fetch(dir).fetch(field),
106
+ "#{config.path}: $.retention.#{dir}.#{field}"
107
+ )
108
+ end
109
+ end
110
+ end
111
+
112
+ RETENTION_ENV.each do |dir, fields|
113
+ fields.each do |field, name|
114
+ next unless env.key?(name)
115
+
116
+ limits.fetch(dir)[field] = parse_positive_integer!(
117
+ env.fetch(name),
118
+ name
119
+ )
120
+ end
121
+ end
122
+
123
+ limits
124
+ end
125
+
126
+ def validate_retention!(retention, path)
127
+ unless retention.is_a?(Hash)
128
+ raise ConfigError, "#{path}: $.retention must be an object"
129
+ end
130
+
131
+ retention.each do |dir, value|
132
+ unless RETENTION_DIRS.include?(dir)
133
+ raise ConfigError,
134
+ "#{path}: $.retention.#{dir} is not supported (expected events, output, or receipts)"
135
+ end
136
+ unless value.is_a?(Hash)
137
+ raise ConfigError, "#{path}: $.retention.#{dir} must be an object"
138
+ end
139
+
140
+ value.each do |field, limit|
141
+ unless RETENTION_FIELDS.include?(field)
142
+ raise ConfigError,
143
+ "#{path}: $.retention.#{dir}.#{field} is not supported"
144
+ end
145
+
146
+ parse_positive_integer!(
147
+ limit,
148
+ "#{path}: $.retention.#{dir}.#{field}"
149
+ )
150
+ end
151
+ end
152
+ end
153
+
154
+ def parse_positive_integer!(value, label)
155
+ text = value.to_s.strip
156
+ unless text.match?(/\A[0-9]+\z/)
157
+ raise ConfigError, "#{label} must be a positive integer"
158
+ end
159
+
160
+ integer = Integer(text)
161
+ raise ConfigError, "#{label} must be a positive integer" if integer <= 0
162
+
163
+ integer
164
+ end
165
+
166
+ def deep_dup_retention_defaults
167
+ DEFAULT_RETENTION_LIMITS.transform_values(&:dup)
168
+ end
169
+ end
170
+ end