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
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
|
|
@@ -42,7 +42,7 @@ module Harnex
|
|
|
42
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 Also mirror the dispatch end record 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
|
|
@@ -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,
|
|
@@ -204,7 +203,6 @@ module Harnex
|
|
|
204
203
|
|
|
205
204
|
repo_root = resolve_run_root(cli_name, child_args)
|
|
206
205
|
validate_repo_phase_policy!(repo_root)
|
|
207
|
-
@options[:summary_out] = resolve_summary_out(repo_root)
|
|
208
206
|
@options[:artifact_report] = resolve_artifact_report(repo_root)
|
|
209
207
|
@options[:id] ||= Harnex.generate_id(repo_root)
|
|
210
208
|
validate_unique_id!(repo_root)
|
|
@@ -273,7 +271,6 @@ module Harnex
|
|
|
273
271
|
end
|
|
274
272
|
tmux_cmd << "--require-attribution" if @options[:require_attribution]
|
|
275
273
|
tmux_cmd << "--allow-live-parent" if @options[:allow_live_parent]
|
|
276
|
-
tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
|
|
277
274
|
tmux_cmd += ["--artifact-report", @options[:artifact_report]] if @options[:artifact_report]
|
|
278
275
|
tmux_cmd << "--require-artifact-report" if @options[:require_artifact_report]
|
|
279
276
|
tmux_cmd += ["--cwd", @options[:cwd]] if @options[:cwd]
|
|
@@ -308,7 +305,9 @@ module Harnex
|
|
|
308
305
|
port: registry["port"],
|
|
309
306
|
mode: "tmux",
|
|
310
307
|
window: window_name,
|
|
311
|
-
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"]
|
|
312
311
|
}
|
|
313
312
|
payload[:description] = @options[:description] if @options[:description]
|
|
314
313
|
puts JSON.generate(payload)
|
|
@@ -347,7 +346,9 @@ module Harnex
|
|
|
347
346
|
port: registry["port"],
|
|
348
347
|
mode: "headless",
|
|
349
348
|
log: log_path,
|
|
350
|
-
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"]
|
|
351
352
|
}
|
|
352
353
|
payload[:description] = @options[:description] if @options[:description]
|
|
353
354
|
puts JSON.generate(payload) if emit_payload
|
|
@@ -435,7 +436,6 @@ module Harnex
|
|
|
435
436
|
watch: watch,
|
|
436
437
|
description: @options[:description],
|
|
437
438
|
meta: @options[:meta],
|
|
438
|
-
summary_out: @options[:summary_out],
|
|
439
439
|
artifact_report_path: @options[:artifact_report],
|
|
440
440
|
require_artifact_report: @options[:require_artifact_report],
|
|
441
441
|
inbox_ttl: @options[:inbox_ttl],
|
|
@@ -624,11 +624,6 @@ module Harnex
|
|
|
624
624
|
when telemetry_equals_regex
|
|
625
625
|
flag = "--#{Regexp.last_match(1)}"
|
|
626
626
|
@options[:telemetry][TELEMETRY_FLAGS.fetch(flag)] = required_option_value(flag, Regexp.last_match(2))
|
|
627
|
-
when "--summary-out"
|
|
628
|
-
index += 1
|
|
629
|
-
@options[:summary_out] = required_option_value(arg, argv[index])
|
|
630
|
-
when /\A--summary-out=(.+)\z/
|
|
631
|
-
@options[:summary_out] = required_option_value("--summary-out", Regexp.last_match(1))
|
|
632
627
|
when "--artifact-report", "--validation-report"
|
|
633
628
|
index += 1
|
|
634
629
|
@options[:artifact_report] = required_option_value(arg, argv[index])
|
|
@@ -720,7 +715,7 @@ module Harnex
|
|
|
720
715
|
nil
|
|
721
716
|
when *VALUE_FLAGS
|
|
722
717
|
index += 1
|
|
723
|
-
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)=/
|
|
724
719
|
nil
|
|
725
720
|
when telemetry_equals_regex
|
|
726
721
|
nil
|
|
@@ -740,7 +735,7 @@ module Harnex
|
|
|
740
735
|
arg == "-h" ||
|
|
741
736
|
arg.start_with?(
|
|
742
737
|
"--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
|
|
743
|
-
"--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=",
|
|
738
|
+
"--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=",
|
|
744
739
|
"--artifact-report=", "--validation-report=", "--cwd=", "--root=", "--timeout=", "--inbox-ttl=",
|
|
745
740
|
*TELEMETRY_EQUALS_PREFIXES
|
|
746
741
|
)
|
|
@@ -771,12 +766,11 @@ module Harnex
|
|
|
771
766
|
raise OptionParser::InvalidOption, "harnex run: --auto-stop requires --context"
|
|
772
767
|
end
|
|
773
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.
|
|
774
772
|
def validate_required_artifact_report!
|
|
775
|
-
|
|
776
|
-
return unless @options[:artifact_report].to_s.strip.empty?
|
|
777
|
-
|
|
778
|
-
raise OptionParser::InvalidOption,
|
|
779
|
-
"harnex run: --require-artifact-report requires --artifact-report PATH"
|
|
773
|
+
true
|
|
780
774
|
end
|
|
781
775
|
|
|
782
776
|
def apply_telemetry_options!
|
|
@@ -867,15 +861,6 @@ module Harnex
|
|
|
867
861
|
raise OptionParser::InvalidOption, "--meta must be valid JSON: #{e.message}"
|
|
868
862
|
end
|
|
869
863
|
|
|
870
|
-
# Explicit-only mirror: the tracked dispatch stream is the canonical
|
|
871
|
-
# destination; --summary-out just duplicates the end record elsewhere.
|
|
872
|
-
def resolve_summary_out(repo_root)
|
|
873
|
-
configured = @options[:summary_out]
|
|
874
|
-
return nil if configured.nil?
|
|
875
|
-
|
|
876
|
-
File.expand_path(configured, repo_root)
|
|
877
|
-
end
|
|
878
|
-
|
|
879
864
|
def resolve_artifact_report(repo_root)
|
|
880
865
|
configured = @options[:artifact_report]
|
|
881
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
data/lib/harnex/config.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Harnex
|
|
|
4
4
|
module Config
|
|
5
5
|
CONFIG_RELATIVE_PATH = File.join(".harnex", "config.json").freeze
|
|
6
6
|
PHASE_POLICIES = %w[warn reject].freeze
|
|
7
|
-
RETENTION_DIRS = %w[events output].freeze
|
|
7
|
+
RETENTION_DIRS = %w[events output receipts].freeze
|
|
8
8
|
RETENTION_FIELDS = %w[max_age_days max_bytes].freeze
|
|
9
9
|
RETENTION_ENV = {
|
|
10
10
|
"events" => {
|
|
@@ -14,6 +14,10 @@ module Harnex
|
|
|
14
14
|
"output" => {
|
|
15
15
|
"max_age_days" => "HARNEX_OUTPUT_MAX_AGE_DAYS",
|
|
16
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"
|
|
17
21
|
}
|
|
18
22
|
}.freeze
|
|
19
23
|
DEFAULT_RETENTION_LIMITS = RETENTION_DIRS.to_h do |dir|
|
|
@@ -127,7 +131,7 @@ module Harnex
|
|
|
127
131
|
retention.each do |dir, value|
|
|
128
132
|
unless RETENTION_DIRS.include?(dir)
|
|
129
133
|
raise ConfigError,
|
|
130
|
-
"#{path}: $.retention.#{dir} is not supported (expected events or
|
|
134
|
+
"#{path}: $.retention.#{dir} is not supported (expected events, output, or receipts)"
|
|
131
135
|
end
|
|
132
136
|
unless value.is_a?(Hash)
|
|
133
137
|
raise ConfigError, "#{path}: $.retention.#{dir} must be an object"
|
data/lib/harnex/core.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
require "fileutils"
|
|
3
|
+
require "open3"
|
|
3
4
|
require "optparse"
|
|
4
5
|
require "securerandom"
|
|
5
6
|
require "set"
|
|
@@ -19,6 +20,8 @@ module Harnex
|
|
|
19
20
|
DEFAULT_PORT_SPAN = Integer(env_value("HARNEX_PORT_SPAN", default: "4000"))
|
|
20
21
|
DEFAULT_ID = "default"
|
|
21
22
|
WATCH_DEBOUNCE_SECONDS = 1.0
|
|
23
|
+
GIT_FINGERPRINT_FULL_BYTES = 4 * 1024 * 1024
|
|
24
|
+
GIT_FINGERPRINT_SAMPLE_BYTES = 64 * 1024
|
|
22
25
|
STATE_DIR = File.expand_path(env_value("HARNEX_STATE_DIR", default: "~/.local/state/harnex"))
|
|
23
26
|
SESSIONS_DIR = File.join(STATE_DIR, "sessions")
|
|
24
27
|
WatchConfig = Struct.new(:absolute_path, :display_path, :hook_message, :debounce_seconds, keyword_init: true)
|
|
@@ -114,42 +117,204 @@ module Harnex
|
|
|
114
117
|
text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
|
|
115
118
|
end
|
|
116
119
|
|
|
117
|
-
def git_capture_start(repo_root)
|
|
120
|
+
def git_capture_start(repo_root, exclude_paths: [])
|
|
118
121
|
sha = git_output(repo_root, "rev-parse", "HEAD")
|
|
119
122
|
branch = git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
120
123
|
return {} if sha.empty? || branch.empty?
|
|
121
124
|
|
|
125
|
+
excluded_paths = normalize_git_excluded_paths(exclude_paths)
|
|
126
|
+
worktree = git_worktree_snapshot(repo_root, exclude_paths: excluded_paths)
|
|
122
127
|
{
|
|
123
128
|
sha: sha,
|
|
124
|
-
branch: branch
|
|
129
|
+
branch: branch,
|
|
130
|
+
worktree: worktree,
|
|
131
|
+
excluded_paths: excluded_paths
|
|
125
132
|
}
|
|
126
133
|
rescue StandardError
|
|
127
134
|
{}
|
|
128
135
|
end
|
|
129
136
|
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
# Captures committed and in-progress work relative to the state observed at
|
|
138
|
+
# session start. Passing the full git_capture_start hash avoids treating an
|
|
139
|
+
# unchanged pre-existing dirty tree as worker activity; a SHA string remains
|
|
140
|
+
# supported for older callers.
|
|
141
|
+
def git_capture_end(repo_root, start, exclude_paths: nil)
|
|
142
|
+
start_capture = start.is_a?(Hash) ? start : {}
|
|
143
|
+
start_sha = (start_capture[:sha] || start_capture["sha"] || start).to_s.strip
|
|
132
144
|
return {} if start_sha.empty?
|
|
133
145
|
|
|
146
|
+
excluded_paths ||= start_capture[:excluded_paths] || start_capture["excluded_paths"] || []
|
|
147
|
+
excluded_paths = normalize_git_excluded_paths(excluded_paths)
|
|
134
148
|
end_sha = git_output(repo_root, "rev-parse", "HEAD")
|
|
135
149
|
range = "#{start_sha}..#{end_sha}"
|
|
136
|
-
shortstat = git_output(repo_root, "diff", "--shortstat", range)
|
|
137
|
-
changed_paths = git_output(repo_root, "diff", "--name-only", range).lines.map(&:strip).reject(&:empty?).first(200)
|
|
138
150
|
commits = Integer(git_output(repo_root, "rev-list", "--count", range))
|
|
139
|
-
|
|
151
|
+
committed_paths = git_null_paths(repo_root, "diff", "--name-only", "-z", range)
|
|
152
|
+
.reject { |path| excluded_paths.include?(path) }
|
|
153
|
+
committed_stats = if committed_paths.empty?
|
|
154
|
+
{ loc_added: 0, loc_removed: 0 }
|
|
155
|
+
else
|
|
156
|
+
parse_git_numstat(
|
|
157
|
+
git_output(repo_root, "diff", "--numstat", range, "--", *committed_paths.first(200))
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
start_worktree = start_capture[:worktree] || start_capture["worktree"]
|
|
162
|
+
end_worktree = git_worktree_snapshot(repo_root, exclude_paths: excluded_paths)
|
|
163
|
+
worktree_paths = changed_worktree_paths(start_worktree, end_worktree)
|
|
164
|
+
tracked_worktree_paths = worktree_paths.select do |path|
|
|
165
|
+
entry = end_worktree.fetch(:entries).fetch(path, nil)
|
|
166
|
+
entry && entry.fetch(:tracked)
|
|
167
|
+
end
|
|
168
|
+
worktree_stats = if tracked_worktree_paths.empty?
|
|
169
|
+
{ loc_added: 0, loc_removed: 0 }
|
|
170
|
+
else
|
|
171
|
+
parse_git_numstat(
|
|
172
|
+
git_output(repo_root, "diff", "--numstat", end_sha, "--", *tracked_worktree_paths.first(200))
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
untracked_stats = untracked_worktree_stats(start_worktree, end_worktree, worktree_paths)
|
|
176
|
+
all_paths = (committed_paths + worktree_paths).uniq
|
|
140
177
|
|
|
141
178
|
{
|
|
142
179
|
sha: end_sha,
|
|
143
|
-
loc_added:
|
|
144
|
-
loc_removed:
|
|
145
|
-
files_changed:
|
|
146
|
-
changed_paths:
|
|
147
|
-
commits: commits
|
|
180
|
+
loc_added: committed_stats.fetch(:loc_added) + worktree_stats.fetch(:loc_added) + untracked_stats.fetch(:loc_added),
|
|
181
|
+
loc_removed: committed_stats.fetch(:loc_removed) + worktree_stats.fetch(:loc_removed) + untracked_stats.fetch(:loc_removed),
|
|
182
|
+
files_changed: all_paths.length,
|
|
183
|
+
changed_paths: all_paths.first(200),
|
|
184
|
+
commits: commits,
|
|
185
|
+
start_dirty: start_worktree ? !start_worktree.fetch(:entries).empty? : nil,
|
|
186
|
+
end_dirty: !end_worktree.fetch(:entries).empty?,
|
|
187
|
+
worktree_changed: !worktree_paths.empty?
|
|
148
188
|
}
|
|
149
189
|
rescue StandardError
|
|
150
190
|
{}
|
|
151
191
|
end
|
|
152
192
|
|
|
193
|
+
def git_worktree_snapshot(repo_root, exclude_paths: [])
|
|
194
|
+
excluded_paths = normalize_git_excluded_paths(exclude_paths)
|
|
195
|
+
tracked_paths = (
|
|
196
|
+
git_null_paths(repo_root, "diff", "--name-only", "-z") +
|
|
197
|
+
git_null_paths(repo_root, "diff", "--cached", "--name-only", "-z")
|
|
198
|
+
).uniq.reject { |path| excluded_paths.include?(path) }
|
|
199
|
+
untracked_paths = git_null_paths(repo_root, "ls-files", "--others", "--exclude-standard", "-z")
|
|
200
|
+
.reject { |path| excluded_paths.include?(path) }
|
|
201
|
+
entries = {}
|
|
202
|
+
tracked_paths.each do |path|
|
|
203
|
+
entries[path] = git_worktree_entry(repo_root, path, tracked: true)
|
|
204
|
+
end
|
|
205
|
+
untracked_paths.each do |path|
|
|
206
|
+
entries[path] = git_worktree_entry(repo_root, path, tracked: false)
|
|
207
|
+
end
|
|
208
|
+
{ entries: entries }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def normalize_git_excluded_paths(paths)
|
|
212
|
+
Array(paths).filter_map do |path|
|
|
213
|
+
text = path.to_s.sub(%r{\A\./}, "")
|
|
214
|
+
text unless text.empty?
|
|
215
|
+
end.uniq
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def git_worktree_entry(repo_root, relative_path, tracked:)
|
|
219
|
+
path = File.join(repo_root.to_s, relative_path)
|
|
220
|
+
stat = File.lstat(path)
|
|
221
|
+
if stat.symlink?
|
|
222
|
+
content = File.readlink(path)
|
|
223
|
+
return {
|
|
224
|
+
tracked: tracked,
|
|
225
|
+
fingerprint: "symlink:#{Digest::SHA256.hexdigest(content)}",
|
|
226
|
+
lines: 0,
|
|
227
|
+
binary: false
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
return { tracked: tracked, fingerprint: "missing", lines: 0, binary: false } unless stat.file?
|
|
232
|
+
|
|
233
|
+
if stat.size > GIT_FINGERPRINT_FULL_BYTES
|
|
234
|
+
first = File.binread(path, GIT_FINGERPRINT_SAMPLE_BYTES)
|
|
235
|
+
last = File.open(path, "rb") do |file|
|
|
236
|
+
file.seek(-[stat.size, GIT_FINGERPRINT_SAMPLE_BYTES].min, IO::SEEK_END)
|
|
237
|
+
file.read(GIT_FINGERPRINT_SAMPLE_BYTES).to_s
|
|
238
|
+
end
|
|
239
|
+
digest = Digest::SHA256.new
|
|
240
|
+
digest.update("#{stat.mode}:#{stat.size}:#{(stat.mtime.to_r * 1_000_000_000).to_i}")
|
|
241
|
+
digest.update(first)
|
|
242
|
+
digest.update(last)
|
|
243
|
+
return {
|
|
244
|
+
tracked: tracked,
|
|
245
|
+
fingerprint: "large:#{digest.hexdigest}",
|
|
246
|
+
lines: 0,
|
|
247
|
+
binary: first.include?("\0") || last.include?("\0")
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
digest = Digest::SHA256.new
|
|
252
|
+
lines = 0
|
|
253
|
+
binary = false
|
|
254
|
+
last_byte = nil
|
|
255
|
+
File.open(path, "rb") do |file|
|
|
256
|
+
buffer = +""
|
|
257
|
+
while file.read(16 * 1024, buffer)
|
|
258
|
+
digest.update(buffer)
|
|
259
|
+
binary ||= buffer.include?("\0")
|
|
260
|
+
lines += buffer.count("\n")
|
|
261
|
+
last_byte = buffer.byteslice(-1)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
lines += 1 if stat.size.positive? && last_byte != "\n"
|
|
265
|
+
{
|
|
266
|
+
tracked: tracked,
|
|
267
|
+
fingerprint: "file:#{stat.mode}:#{digest.hexdigest}",
|
|
268
|
+
lines: binary ? 0 : lines,
|
|
269
|
+
binary: binary
|
|
270
|
+
}
|
|
271
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR
|
|
272
|
+
{ tracked: tracked, fingerprint: "missing", lines: 0, binary: false }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def changed_worktree_paths(start_worktree, end_worktree)
|
|
276
|
+
end_entries = end_worktree.fetch(:entries)
|
|
277
|
+
return end_entries.keys.sort unless start_worktree.is_a?(Hash)
|
|
278
|
+
|
|
279
|
+
start_entries = start_worktree[:entries] || start_worktree["entries"] || {}
|
|
280
|
+
(start_entries.keys + end_entries.keys).uniq.select do |path|
|
|
281
|
+
start_entries[path] != end_entries[path]
|
|
282
|
+
end.sort
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def untracked_worktree_stats(start_worktree, end_worktree, changed_paths)
|
|
286
|
+
start_entries = if start_worktree.is_a?(Hash)
|
|
287
|
+
start_worktree[:entries] || start_worktree["entries"] || {}
|
|
288
|
+
else
|
|
289
|
+
{}
|
|
290
|
+
end
|
|
291
|
+
end_entries = end_worktree.fetch(:entries)
|
|
292
|
+
changed_paths.each_with_object({ loc_added: 0, loc_removed: 0 }) do |path, stats|
|
|
293
|
+
before = start_entries[path]
|
|
294
|
+
after = end_entries[path]
|
|
295
|
+
next if after && after.fetch(:tracked)
|
|
296
|
+
next if before && before.fetch(:tracked)
|
|
297
|
+
|
|
298
|
+
stats[:loc_removed] += before.fetch(:lines) if before
|
|
299
|
+
stats[:loc_added] += after.fetch(:lines) if after
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def git_null_paths(repo_root, *args)
|
|
304
|
+
output, _stderr, status = Open3.capture3("git", "-C", repo_root.to_s, *args)
|
|
305
|
+
raise "git #{args.join(' ')} failed" unless status.success?
|
|
306
|
+
|
|
307
|
+
output.split("\0").reject(&:empty?)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def parse_git_numstat(text)
|
|
311
|
+
text.to_s.lines.each_with_object({ loc_added: 0, loc_removed: 0 }) do |line, stats|
|
|
312
|
+
added, removed, = line.split("\t", 3)
|
|
313
|
+
stats[:loc_added] += Integer(added, exception: false).to_i
|
|
314
|
+
stats[:loc_removed] += Integer(removed, exception: false).to_i
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
153
318
|
# Canonical path resolution shared by every registry/exit/events writer and
|
|
154
319
|
# reader. realpath collapses symlinked prefixes so a session registered from
|
|
155
320
|
# a symlinked cwd stays visible to checkers resolving the physical path.
|
|
@@ -285,6 +450,12 @@ module Harnex
|
|
|
285
450
|
false
|
|
286
451
|
rescue Errno::EPERM
|
|
287
452
|
true
|
|
453
|
+
rescue ArgumentError, TypeError
|
|
454
|
+
# A non-numeric pid means a truncated, hand-edited, or foreign-written
|
|
455
|
+
# registry file. Report it dead so active_sessions prunes it, the same
|
|
456
|
+
# self-healing it already applies to unparseable JSON. Raising here would
|
|
457
|
+
# take down every command that scans sessions -- status, send, pane.
|
|
458
|
+
false
|
|
288
459
|
end
|
|
289
460
|
|
|
290
461
|
def read_registry(repo_root, id = DEFAULT_ID, cli: nil)
|
|
@@ -352,10 +523,25 @@ module Harnex
|
|
|
352
523
|
nil
|
|
353
524
|
end
|
|
354
525
|
|
|
355
|
-
|
|
356
|
-
|
|
526
|
+
# Atomically replace a JSON state file.
|
|
527
|
+
#
|
|
528
|
+
# The temp name must be unique per write, not just per process. Several
|
|
529
|
+
# threads in one session write the same registry path — the startup persist,
|
|
530
|
+
# the inbox delivery thread, and one thread per API client — and a shared
|
|
531
|
+
# temp name lets one thread rename the file another is still writing, which
|
|
532
|
+
# surfaces as ENOENT on the loser's rename. mkdir_p covers a state directory
|
|
533
|
+
# that was reaped (tmpfs, operator cleanup) while the process was live.
|
|
534
|
+
def atomic_write_json(path, payload)
|
|
535
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
536
|
+
tmp = "#{path}.tmp.#{Process.pid}.#{SecureRandom.hex(6)}"
|
|
357
537
|
File.write(tmp, JSON.pretty_generate(payload))
|
|
358
538
|
File.rename(tmp, path)
|
|
539
|
+
ensure
|
|
540
|
+
FileUtils.rm_f(tmp) if tmp
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def write_registry(path, payload)
|
|
544
|
+
atomic_write_json(path, payload)
|
|
359
545
|
end
|
|
360
546
|
|
|
361
547
|
def allocate_port(repo_root, id, requested_port = nil, host: DEFAULT_HOST)
|
|
@@ -156,8 +156,9 @@ module Harnex
|
|
|
156
156
|
repo_root: session.repo_root,
|
|
157
157
|
tier: session.__send__(:meta_hash)["tier"],
|
|
158
158
|
meta: session.__send__(:meta_hash),
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
events_log_path: session.events_log_path,
|
|
160
|
+
artifact_report_path: session.artifact_report_path,
|
|
161
|
+
artifact_claims_path: session.artifact_claims_path
|
|
161
162
|
}
|
|
162
163
|
end
|
|
163
164
|
|
|
@@ -182,7 +183,6 @@ module Harnex
|
|
|
182
183
|
terminal_event: terminal_event,
|
|
183
184
|
commit_sha: commit_sha(session.git_start, session.git_end),
|
|
184
185
|
tier: session.__send__(:meta_hash)["tier"],
|
|
185
|
-
summary_out_path: session.summary_out,
|
|
186
186
|
events_log_path: session.events_log_path,
|
|
187
187
|
tmux_state: tmux_state(session.__send__(:summary_tmux_session))
|
|
188
188
|
}.merge(session.__send__(:build_summary_record))
|
data/lib/harnex/retention.rb
CHANGED
|
@@ -7,7 +7,8 @@ module Harnex
|
|
|
7
7
|
module Retention
|
|
8
8
|
DIRS = {
|
|
9
9
|
"events" => "events",
|
|
10
|
-
"output" => "output"
|
|
10
|
+
"output" => "output",
|
|
11
|
+
"receipts" => "receipts"
|
|
11
12
|
}.freeze
|
|
12
13
|
THROTTLE_SECONDS = 3600
|
|
13
14
|
MAX_REPORTED_PATHS = 100
|
|
@@ -184,6 +185,8 @@ module Harnex
|
|
|
184
185
|
|
|
185
186
|
protect(paths, data["events_log_path"])
|
|
186
187
|
protect(paths, data["output_log_path"])
|
|
188
|
+
protect(paths, data["artifact_report_path"])
|
|
189
|
+
protect(paths, data["artifact_claims_path"])
|
|
187
190
|
protect_derived_session_paths(paths, data)
|
|
188
191
|
rescue JSON::ParserError, ArgumentError, TypeError, Errno::ENOENT, Errno::EACCES
|
|
189
192
|
next
|
|
@@ -200,6 +203,8 @@ module Harnex
|
|
|
200
203
|
live_start_records(path).each do |record|
|
|
201
204
|
protect(paths, record["events_log_path"])
|
|
202
205
|
protect(paths, record["output_log_path"])
|
|
206
|
+
protect(paths, record["artifact_report_path"])
|
|
207
|
+
protect(paths, record["artifact_claims_path"])
|
|
203
208
|
protect_derived_session_paths(paths, record)
|
|
204
209
|
end
|
|
205
210
|
end
|
|
@@ -243,6 +248,12 @@ module Harnex
|
|
|
243
248
|
|
|
244
249
|
protect(paths, Harnex.events_log_path(repo, id))
|
|
245
250
|
protect(paths, Harnex.output_log_path(repo, id))
|
|
251
|
+
session_id = data["session_id"].to_s
|
|
252
|
+
unless session_id.empty?
|
|
253
|
+
receipt = Harnex::ArtifactReport.default_path(repo_root: repo, id: id, session_id: session_id)
|
|
254
|
+
protect(paths, receipt)
|
|
255
|
+
protect(paths, Harnex::ArtifactReport.claims_path(receipt))
|
|
256
|
+
end
|
|
246
257
|
rescue StandardError
|
|
247
258
|
nil
|
|
248
259
|
end
|
|
@@ -301,9 +312,7 @@ module Harnex
|
|
|
301
312
|
end
|
|
302
313
|
}
|
|
303
314
|
}
|
|
304
|
-
|
|
305
|
-
File.write(tmp, JSON.pretty_generate(payload))
|
|
306
|
-
File.rename(tmp, METADATA_PATH)
|
|
315
|
+
Harnex.atomic_write_json(METADATA_PATH, payload)
|
|
307
316
|
rescue StandardError => e
|
|
308
317
|
warn("harnex: failed to persist retention metadata: #{e.message}")
|
|
309
318
|
end
|