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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +188 -0
- data/GUIDE.md +17 -10
- data/README.md +61 -54
- data/TECHNICAL.md +38 -15
- data/docs/codex-appserver.md +175 -0
- data/docs/configuration.md +118 -0
- data/docs/dispatch-telemetry.md +502 -0
- data/docs/events.md +132 -0
- data/guides/01_dispatch.md +31 -28
- data/guides/04_monitoring.md +10 -9
- data/guides/05_naming.md +18 -8
- data/lib/harnex/adapters/base.rb +10 -0
- data/lib/harnex/adapters/codex_appserver.rb +25 -0
- 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 +38 -4
- data/lib/harnex/commands/history.rb +7 -3
- data/lib/harnex/commands/run.rb +55 -43
- data/lib/harnex/commands/status.rb +0 -2
- data/lib/harnex/commands/wait.rb +0 -1
- data/lib/harnex/config.rb +170 -0
- data/lib/harnex/core.rb +200 -21
- data/lib/harnex/dispatch_history.rb +16 -7
- data/lib/harnex/pricing.rb +135 -0
- data/lib/harnex/retention.rb +320 -0
- data/lib/harnex/runtime/session.rb +451 -166
- data/lib/harnex/terminal_status.rb +18 -21
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- metadata +9 -2
|
@@ -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
|
-
|
|
101
|
-
|
|
101
|
+
if DispatchHistory.start_record?(record)
|
|
102
|
+
next nil if start_completed?(record, ended)
|
|
102
103
|
|
|
103
|
-
|
|
104
|
+
derive_live_record(record)
|
|
105
|
+
elsif DispatchHistory.end_record?(record)
|
|
106
|
+
record
|
|
107
|
+
end
|
|
104
108
|
end
|
|
105
109
|
end
|
|
106
110
|
|
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -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
|
|
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 --
|
|
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
|
-
|
|
71
|
+
Override the harness-authored observed-state receipt path
|
|
73
72
|
--validation-report PATH
|
|
74
|
-
|
|
73
|
+
Compatibility alias for --artifact-report
|
|
75
74
|
--require-artifact-report
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
128
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
384
|
-
raise "harnex run: --attempt-kind
|
|
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|
|
|
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=",
|
|
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
|
-
|
|
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
|
data/lib/harnex/commands/wait.rb
CHANGED
|
@@ -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
|