harnex 0.6.4 → 0.7.3
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 +170 -0
- data/README.md +71 -21
- data/TECHNICAL.md +24 -0
- data/guides/01_dispatch.md +22 -0
- data/lib/harnex/adapters/base.rb +33 -0
- data/lib/harnex/adapters/claude.rb +4 -0
- data/lib/harnex/adapters/codex.rb +4 -0
- data/lib/harnex/adapters/codex_appserver.rb +85 -200
- data/lib/harnex/adapters/generic.rb +11 -0
- data/lib/harnex/adapters/opencode.rb +132 -0
- data/lib/harnex/adapters.rb +3 -1
- data/lib/harnex/cli.rb +8 -1
- data/lib/harnex/codex/app_server/client.rb +348 -0
- data/lib/harnex/commands/doctor.rb +95 -2
- data/lib/harnex/commands/history.rb +149 -0
- data/lib/harnex/commands/run.rb +62 -6
- data/lib/harnex/commands/wait.rb +77 -36
- data/lib/harnex/core.rb +3 -3
- data/lib/harnex/dispatch_history.rb +112 -0
- data/lib/harnex/runtime/session.rb +307 -46
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +2 -0
- metadata +6 -2
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Harnex
|
|
|
8
8
|
KNOWN_FLAGS = %w[
|
|
9
9
|
--id --description --detach --tmux --host --port --watch --watch-file
|
|
10
10
|
--stall-after --max-resumes --preset --context --meta --summary-out
|
|
11
|
-
--timeout --inbox-ttl --legacy-pty --help
|
|
11
|
+
--timeout --inbox-ttl --auto-stop --fast --legacy-pty --help
|
|
12
12
|
].freeze
|
|
13
13
|
VALUE_FLAGS = %w[
|
|
14
14
|
--id --description --host --port --watch --watch-file --stall-after
|
|
@@ -32,6 +32,9 @@ module Harnex
|
|
|
32
32
|
--preset NAME Watch preset: impl, plan, gate (requires --watch)
|
|
33
33
|
--watch-file PATH Auto-send a file-change hook on modification
|
|
34
34
|
--context TEXT Inject as the initial prompt (prepends session header)
|
|
35
|
+
--auto-stop Stop after the first task completion from --context
|
|
36
|
+
--fast (codex only) Use Codex service_tier="fast".
|
|
37
|
+
Default Codex runs force service_tier="flex".
|
|
35
38
|
--meta JSON Attach parsed JSON metadata to the started event
|
|
36
39
|
--summary-out PATH Append dispatch telemetry summary JSONL to PATH
|
|
37
40
|
--timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
|
|
@@ -45,6 +48,7 @@ module Harnex
|
|
|
45
48
|
Notes:
|
|
46
49
|
Compatibility: `--watch PATH` and `--watch=PATH` still configure file-hook mode.
|
|
47
50
|
Bare `--watch` enables the babysitter.
|
|
51
|
+
--auto-stop requires --context and fires once after the first completion.
|
|
48
52
|
Explicit --stall-after/--max-resumes values override --preset defaults.
|
|
49
53
|
CLIs with smart prompt detection: #{Adapters.known.join(', ')}
|
|
50
54
|
Any other CLI name is launched with generic wrapping.
|
|
@@ -52,6 +56,7 @@ module Harnex
|
|
|
52
56
|
|
|
53
57
|
Common patterns:
|
|
54
58
|
#{program_name} codex --id cx-i-42 --tmux cx-i-42 --context "Read /tmp/task-impl-42.md"
|
|
59
|
+
#{program_name} codex --id cx-i-42 --tmux cx-i-42 --context "Read /tmp/task-impl-42.md" --auto-stop
|
|
55
60
|
#{program_name} codex --id cx-i-42 --watch --preset impl --context "Read /tmp/task-impl-42.md"
|
|
56
61
|
#{program_name} claude --id cl-r-42 --tmux cl-r-42 --description "Review task 42"
|
|
57
62
|
|
|
@@ -60,11 +65,14 @@ module Harnex
|
|
|
60
65
|
Passing --tmux without --id creates a random harnex session ID.
|
|
61
66
|
--watch is foreground-only; do not combine it with --tmux or --detach.
|
|
62
67
|
Use -- before child CLI flags when a flag could be parsed by harnex.
|
|
68
|
+
Codex JSON-RPC: pass model as `-c model=NAME`, not `-m NAME`. The
|
|
69
|
+
legacy PTY adapter (--legacy-pty) accepts `-m`.
|
|
63
70
|
TEXT
|
|
64
71
|
end
|
|
65
72
|
|
|
66
73
|
def initialize(argv)
|
|
67
74
|
@argv = argv.dup
|
|
75
|
+
@launch_cwd = Dir.pwd
|
|
68
76
|
@options = {
|
|
69
77
|
id: nil,
|
|
70
78
|
description: nil,
|
|
@@ -80,11 +88,13 @@ module Harnex
|
|
|
80
88
|
context: nil,
|
|
81
89
|
meta: nil,
|
|
82
90
|
summary_out: nil,
|
|
91
|
+
auto_stop: false,
|
|
83
92
|
detach: false,
|
|
84
93
|
tmux: false,
|
|
85
94
|
tmux_name: nil,
|
|
86
95
|
timeout: DEFAULT_TIMEOUT,
|
|
87
96
|
inbox_ttl: default_inbox_ttl,
|
|
97
|
+
fast: false,
|
|
88
98
|
legacy_pty: false,
|
|
89
99
|
help: false
|
|
90
100
|
}
|
|
@@ -98,12 +108,13 @@ module Harnex
|
|
|
98
108
|
end
|
|
99
109
|
|
|
100
110
|
raise OptionParser::MissingArgument, "cli" if cli_name.nil?
|
|
111
|
+
validate_auto_stop_context!
|
|
101
112
|
|
|
102
113
|
repo_root = Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
|
|
103
114
|
@options[:summary_out] = resolve_summary_out(repo_root)
|
|
104
115
|
@options[:id] ||= Harnex.generate_id(repo_root)
|
|
105
116
|
validate_unique_id!(repo_root)
|
|
106
|
-
effective_child_args = apply_context(child_args)
|
|
117
|
+
effective_child_args = apply_context(apply_codex_service_tier(cli_name, child_args))
|
|
107
118
|
adapter = Harnex.build_adapter(cli_name, effective_child_args, legacy_pty: @options[:legacy_pty])
|
|
108
119
|
@options[:detach] = true if @options[:tmux]
|
|
109
120
|
validate_watch_mode!
|
|
@@ -159,9 +170,11 @@ module Harnex
|
|
|
159
170
|
tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
|
|
160
171
|
tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
|
|
161
172
|
tmux_cmd += ["--context", @options[:context]] if @options[:context]
|
|
173
|
+
tmux_cmd << "--auto-stop" if @options[:auto_stop]
|
|
162
174
|
tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
|
|
163
175
|
tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
|
|
164
176
|
tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
|
|
177
|
+
tmux_cmd << "--fast" if @options[:fast]
|
|
165
178
|
tmux_cmd += ["--legacy-pty"] if @options[:legacy_pty]
|
|
166
179
|
tmux_cmd += ["--"] + child_args unless child_args.empty?
|
|
167
180
|
|
|
@@ -170,9 +183,9 @@ module Harnex
|
|
|
170
183
|
|
|
171
184
|
started =
|
|
172
185
|
if ENV["TMUX"]
|
|
173
|
-
system("tmux", "new-window", "-n", window_name, "-d", shell_cmd)
|
|
186
|
+
system("tmux", "new-window", "-c", @launch_cwd, "-n", window_name, "-d", shell_cmd)
|
|
174
187
|
else
|
|
175
|
-
system("tmux", "new-session", "-d", "-s", "harnex", "-n", window_name, shell_cmd)
|
|
188
|
+
system("tmux", "new-session", "-c", @launch_cwd, "-d", "-s", "harnex", "-n", window_name, shell_cmd)
|
|
176
189
|
end
|
|
177
190
|
|
|
178
191
|
raise "tmux failed to start #{cli_name.inspect}" unless started
|
|
@@ -266,7 +279,9 @@ module Harnex
|
|
|
266
279
|
description: @options[:description],
|
|
267
280
|
meta: @options[:meta],
|
|
268
281
|
summary_out: @options[:summary_out],
|
|
269
|
-
inbox_ttl: @options[:inbox_ttl]
|
|
282
|
+
inbox_ttl: @options[:inbox_ttl],
|
|
283
|
+
auto_stop: @options[:auto_stop],
|
|
284
|
+
launch_cwd: @launch_cwd
|
|
270
285
|
)
|
|
271
286
|
end
|
|
272
287
|
|
|
@@ -412,6 +427,10 @@ module Harnex
|
|
|
412
427
|
@options[:context] = required_option_value(arg, argv[index])
|
|
413
428
|
when /\A--context=(.+)\z/
|
|
414
429
|
@options[:context] = required_option_value("--context", Regexp.last_match(1))
|
|
430
|
+
when "--auto-stop"
|
|
431
|
+
@options[:auto_stop] = true
|
|
432
|
+
when "--fast"
|
|
433
|
+
@options[:fast] = true
|
|
415
434
|
when "--meta"
|
|
416
435
|
index += 1
|
|
417
436
|
@options[:meta] = parse_meta(required_option_value(arg, argv[index]))
|
|
@@ -435,6 +454,7 @@ module Harnex
|
|
|
435
454
|
when "--legacy-pty"
|
|
436
455
|
@options[:legacy_pty] = true
|
|
437
456
|
else
|
|
457
|
+
reject_unknown_long_flag!(arg) if unknown_long_flag?(arg)
|
|
438
458
|
if cli_name.nil?
|
|
439
459
|
cli_name = arg
|
|
440
460
|
else
|
|
@@ -447,6 +467,16 @@ module Harnex
|
|
|
447
467
|
[cli_name, forwarded]
|
|
448
468
|
end
|
|
449
469
|
|
|
470
|
+
def unknown_long_flag?(arg)
|
|
471
|
+
arg.start_with?("--")
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def reject_unknown_long_flag!(arg)
|
|
475
|
+
flag = arg.split("=", 2).first
|
|
476
|
+
raise OptionParser::InvalidOption,
|
|
477
|
+
"harnex run: unknown flag #{flag}; see harnex run --help"
|
|
478
|
+
end
|
|
479
|
+
|
|
450
480
|
def required_option_value(option_name, value)
|
|
451
481
|
raise OptionParser::MissingArgument, option_name if value.nil?
|
|
452
482
|
raise OptionParser::MissingArgument, option_name if value.match?(/\A-[A-Za-z]/)
|
|
@@ -473,7 +503,7 @@ module Harnex
|
|
|
473
503
|
case arg
|
|
474
504
|
when "--"
|
|
475
505
|
return false
|
|
476
|
-
when "-h", "--help", "--detach", "--tmux", "--legacy-pty"
|
|
506
|
+
when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--fast", "--legacy-pty"
|
|
477
507
|
nil
|
|
478
508
|
when /\A--tmux=/
|
|
479
509
|
nil
|
|
@@ -520,6 +550,32 @@ module Harnex
|
|
|
520
550
|
@options[:max_resumes] = preset[:max_resumes] unless @options[:max_resumes_explicit]
|
|
521
551
|
end
|
|
522
552
|
|
|
553
|
+
def validate_auto_stop_context!
|
|
554
|
+
return unless @options[:auto_stop]
|
|
555
|
+
return if @options[:context]
|
|
556
|
+
|
|
557
|
+
raise OptionParser::InvalidOption, "harnex run: --auto-stop requires --context"
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def apply_codex_service_tier(cli_name, child_args)
|
|
561
|
+
return child_args unless cli_name.to_s == "codex"
|
|
562
|
+
return child_args if child_service_tier_config?(child_args)
|
|
563
|
+
|
|
564
|
+
child_args + ["-c", "service_tier=\"#{@options[:fast] ? 'fast' : 'flex'}\""]
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def child_service_tier_config?(child_args)
|
|
568
|
+
child_args.each_with_index.any? do |arg, index|
|
|
569
|
+
text = arg.to_s
|
|
570
|
+
text.start_with?("service_tier=") ||
|
|
571
|
+
text.start_with?("service_tier.") ||
|
|
572
|
+
text == "-c" && child_args[index + 1].to_s.start_with?("service_tier") ||
|
|
573
|
+
text == "--config" && child_args[index + 1].to_s.start_with?("service_tier") ||
|
|
574
|
+
text.start_with?("-cservice_tier") ||
|
|
575
|
+
text.start_with?("--config=service_tier")
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
523
579
|
def parse_non_negative_integer(value, option_name:)
|
|
524
580
|
integer = Integer(value)
|
|
525
581
|
raise OptionParser::InvalidArgument, "#{option_name} must be 0 or greater" if integer.negative?
|
data/lib/harnex/commands/wait.rb
CHANGED
|
@@ -6,8 +6,13 @@ require "uri"
|
|
|
6
6
|
module Harnex
|
|
7
7
|
class Waiter
|
|
8
8
|
POLL_INTERVAL = 0.5
|
|
9
|
+
EVENT_POLL_INTERVAL = 0.1
|
|
10
|
+
EXIT_STATUS_GRACE_SECONDS_DEFAULT = 5.0
|
|
11
|
+
EXIT_STATUS_GRACE_POLL_INTERVAL = 0.05
|
|
12
|
+
FINAL_EVENT_GRACE_SECONDS = 5.0
|
|
9
13
|
|
|
10
14
|
EVENT_PREDICATES = %w[task_complete].freeze
|
|
15
|
+
LEGACY_EVENT_TYPES = %w[agent_state exited task_complete].freeze
|
|
11
16
|
|
|
12
17
|
def self.usage(program_name = "harnex wait")
|
|
13
18
|
<<~TEXT
|
|
@@ -84,30 +89,17 @@ module Harnex
|
|
|
84
89
|
|
|
85
90
|
offset = 0
|
|
86
91
|
task_complete_seen = false
|
|
92
|
+
final_event_deadline = nil
|
|
87
93
|
|
|
88
94
|
# Replay existing events first — we may already be past the predicate.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
f.each_line do |line|
|
|
92
|
-
offset = f.pos
|
|
93
|
-
event = parse_event(line)
|
|
94
|
-
next unless event
|
|
95
|
-
task_complete_seen = true if event["type"] == "task_complete"
|
|
96
|
-
if matches?(event, predicate, task_complete_seen)
|
|
97
|
-
return emit_event_match(event, start_time)
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
95
|
+
status, offset, task_complete_seen = scan_events(events_path, offset, predicate, task_complete_seen, start_time)
|
|
96
|
+
return status if status
|
|
102
97
|
|
|
103
98
|
target_pid = registry && registry["pid"]
|
|
104
99
|
|
|
105
100
|
loop do
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
puts JSON.generate(ok: false, id: @options[:id], state: "exited", waited_seconds: waited)
|
|
109
|
-
return 1
|
|
110
|
-
end
|
|
101
|
+
status, offset, task_complete_seen = scan_events(events_path, offset, predicate, task_complete_seen, start_time)
|
|
102
|
+
return status if status
|
|
111
103
|
|
|
112
104
|
if deadline && Time.now >= deadline
|
|
113
105
|
waited = (Time.now - start_time).round(1)
|
|
@@ -115,34 +107,62 @@ module Harnex
|
|
|
115
107
|
return 124
|
|
116
108
|
end
|
|
117
109
|
|
|
118
|
-
if
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
task_complete_seen = true if event["type"] == "task_complete"
|
|
125
|
-
if matches?(event, predicate, task_complete_seen)
|
|
126
|
-
offset = f.pos
|
|
127
|
-
return emit_event_match(event, start_time)
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
offset = f.pos
|
|
110
|
+
if target_pid && !Harnex.alive_pid?(target_pid)
|
|
111
|
+
final_event_deadline ||= Time.now + FINAL_EVENT_GRACE_SECONDS
|
|
112
|
+
if Time.now >= final_event_deadline
|
|
113
|
+
waited = (Time.now - start_time).round(1)
|
|
114
|
+
puts JSON.generate(ok: false, id: @options[:id], state: "exited", waited_seconds: waited)
|
|
115
|
+
return 1
|
|
131
116
|
end
|
|
117
|
+
else
|
|
118
|
+
final_event_deadline = nil
|
|
132
119
|
end
|
|
133
120
|
|
|
134
|
-
sleep
|
|
121
|
+
sleep EVENT_POLL_INTERVAL
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def scan_events(path, offset, predicate, task_complete_seen, start_time)
|
|
126
|
+
return [nil, offset, task_complete_seen] unless File.exist?(path) && File.size(path) > offset
|
|
127
|
+
|
|
128
|
+
File.open(path, "r") do |f|
|
|
129
|
+
f.seek(offset)
|
|
130
|
+
f.each_line do |line|
|
|
131
|
+
event = parse_event(line)
|
|
132
|
+
next unless event
|
|
133
|
+
|
|
134
|
+
task_complete_seen = true if event_type(event) == "task_complete"
|
|
135
|
+
if matches?(event, predicate, task_complete_seen)
|
|
136
|
+
return [emit_event_match(event, start_time), f.pos, task_complete_seen]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
offset = f.pos
|
|
135
140
|
end
|
|
141
|
+
|
|
142
|
+
[nil, offset, task_complete_seen]
|
|
136
143
|
end
|
|
137
144
|
|
|
138
145
|
def parse_event(line)
|
|
139
|
-
JSON.parse(line)
|
|
146
|
+
event = JSON.parse(line)
|
|
147
|
+
event.is_a?(Hash) ? event : nil
|
|
140
148
|
rescue JSON::ParserError
|
|
141
|
-
|
|
149
|
+
legacy_type = line.to_s.strip
|
|
150
|
+
return nil unless LEGACY_EVENT_TYPES.include?(legacy_type)
|
|
151
|
+
|
|
152
|
+
{ "type" => legacy_type }
|
|
142
153
|
end
|
|
143
154
|
|
|
144
|
-
def
|
|
155
|
+
def event_type(event)
|
|
145
156
|
type = event["type"]
|
|
157
|
+
return type if type.is_a?(String) && !type.empty?
|
|
158
|
+
|
|
159
|
+
legacy_type = event["terminal_event"] || event["event"]
|
|
160
|
+
legacy_type = legacy_type.to_s
|
|
161
|
+
LEGACY_EVENT_TYPES.include?(legacy_type) ? legacy_type : nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def matches?(event, predicate, task_complete_seen)
|
|
165
|
+
type = event_type(event)
|
|
146
166
|
case predicate
|
|
147
167
|
when "task_complete"
|
|
148
168
|
type == "task_complete"
|
|
@@ -159,7 +179,7 @@ module Harnex
|
|
|
159
179
|
puts JSON.generate(
|
|
160
180
|
ok: true,
|
|
161
181
|
id: @options[:id],
|
|
162
|
-
event: event
|
|
182
|
+
event: event_type(event),
|
|
163
183
|
seq: event["seq"],
|
|
164
184
|
waited_seconds: waited
|
|
165
185
|
)
|
|
@@ -227,6 +247,7 @@ module Harnex
|
|
|
227
247
|
|
|
228
248
|
loop do
|
|
229
249
|
unless Harnex.alive_pid?(target_pid)
|
|
250
|
+
await_exit_status(exit_path)
|
|
230
251
|
return read_exit_status(exit_path, @options[:id])
|
|
231
252
|
end
|
|
232
253
|
|
|
@@ -239,6 +260,26 @@ module Harnex
|
|
|
239
260
|
end
|
|
240
261
|
end
|
|
241
262
|
|
|
263
|
+
# Subprocess death races the parent's DISPATCH-row write; the exit-status
|
|
264
|
+
# file is written *after* the row, so polling it bounds the race.
|
|
265
|
+
def await_exit_status(exit_path)
|
|
266
|
+
return if File.exist?(exit_path)
|
|
267
|
+
|
|
268
|
+
grace_deadline = Time.now + exit_status_grace_seconds
|
|
269
|
+
until File.exist?(exit_path) || Time.now >= grace_deadline
|
|
270
|
+
sleep EXIT_STATUS_GRACE_POLL_INTERVAL
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def exit_status_grace_seconds
|
|
275
|
+
override = ENV["HARNEX_EXIT_STATUS_GRACE_SECONDS"]
|
|
276
|
+
return EXIT_STATUS_GRACE_SECONDS_DEFAULT if override.to_s.strip.empty?
|
|
277
|
+
|
|
278
|
+
Float(override)
|
|
279
|
+
rescue ArgumentError
|
|
280
|
+
EXIT_STATUS_GRACE_SECONDS_DEFAULT
|
|
281
|
+
end
|
|
282
|
+
|
|
242
283
|
def fetch_agent_state(host, port, token)
|
|
243
284
|
uri = URI("http://#{host}:#{port}/status")
|
|
244
285
|
request = Net::HTTP::Get.new(uri)
|
data/lib/harnex/core.rb
CHANGED
|
@@ -85,10 +85,10 @@ module Harnex
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def default_summary_out_path(repo_root)
|
|
88
|
-
|
|
89
|
-
return nil
|
|
88
|
+
root = repo_root.to_s
|
|
89
|
+
return nil if root.empty?
|
|
90
90
|
|
|
91
|
-
File.join(
|
|
91
|
+
File.join(root, ".harnex", "dispatch.jsonl")
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
def git_capture_start(repo_root)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "open3"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Harnex
|
|
7
|
+
module DispatchHistory
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
MAX_REPO_WALK_LEVELS = 10
|
|
11
|
+
|
|
12
|
+
def global_path
|
|
13
|
+
File.join(STATE_DIR, "dispatch.jsonl")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def path_for(start_path = Dir.pwd, global: false)
|
|
17
|
+
return global_path if global
|
|
18
|
+
|
|
19
|
+
repo_root = find_git_root(start_path)
|
|
20
|
+
return global_path unless repo_root
|
|
21
|
+
|
|
22
|
+
File.join(repo_root, ".harnex", "dispatch.jsonl")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def find_git_root(start_path)
|
|
26
|
+
path = File.expand_path(start_path.to_s.empty? ? Dir.pwd : start_path)
|
|
27
|
+
path = File.dirname(path) unless File.directory?(path)
|
|
28
|
+
|
|
29
|
+
git_root = git_toplevel(path)
|
|
30
|
+
return git_root if git_root
|
|
31
|
+
|
|
32
|
+
(MAX_REPO_WALK_LEVELS + 1).times do
|
|
33
|
+
return path if File.directory?(File.join(path, ".git"))
|
|
34
|
+
|
|
35
|
+
parent = File.dirname(path)
|
|
36
|
+
break if parent == path
|
|
37
|
+
|
|
38
|
+
path = parent
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def git_toplevel(path)
|
|
45
|
+
output, status = Open3.capture2("git", "-C", path, "rev-parse", "--show-toplevel", err: File::NULL)
|
|
46
|
+
root = output.to_s.strip
|
|
47
|
+
return root if status.success? && !root.empty?
|
|
48
|
+
|
|
49
|
+
nil
|
|
50
|
+
rescue StandardError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def append(path, record)
|
|
55
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
56
|
+
line = JSON.generate(record) + "\n"
|
|
57
|
+
File.open(path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |file|
|
|
58
|
+
file.flock(File::LOCK_EX)
|
|
59
|
+
file.write(line)
|
|
60
|
+
ensure
|
|
61
|
+
file.flock(File::LOCK_UN) unless file.closed?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_record(session)
|
|
66
|
+
ended_at = session.ended_at || Time.now
|
|
67
|
+
status, terminal_event = classify(session)
|
|
68
|
+
{
|
|
69
|
+
schema_version: 1,
|
|
70
|
+
id: session.id,
|
|
71
|
+
description: session.description,
|
|
72
|
+
cli: session.adapter.key,
|
|
73
|
+
started_at: session.started_at.utc.iso8601,
|
|
74
|
+
ended_at: ended_at.utc.iso8601,
|
|
75
|
+
duration_s: (ended_at - session.started_at).to_i,
|
|
76
|
+
status: status,
|
|
77
|
+
terminal_event: terminal_event,
|
|
78
|
+
commit_sha: commit_sha(session.git_start, session.git_end),
|
|
79
|
+
tier: session.__send__(:meta_hash)["tier"],
|
|
80
|
+
meta: session.__send__(:meta_hash),
|
|
81
|
+
summary_out_path: session.summary_out,
|
|
82
|
+
events_log_path: session.events_log_path,
|
|
83
|
+
tmux_state: tmux_state(session.__send__(:summary_tmux_session))
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def classify(session)
|
|
88
|
+
return ["completed", "task_complete"] if session.task_complete?
|
|
89
|
+
return ["timeout", "timeout"] if session.exit_code == 124
|
|
90
|
+
return ["killed", "process_kill"] if session.term_signal
|
|
91
|
+
return ["completed", "process_exit"] if session.exit_code == 0
|
|
92
|
+
|
|
93
|
+
["failed", "dispatch_failed"]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def commit_sha(git_start, git_end)
|
|
97
|
+
start_sha = git_start[:sha].to_s
|
|
98
|
+
end_sha = git_end[:sha].to_s
|
|
99
|
+
return nil if start_sha.empty? || end_sha.empty? || start_sha == end_sha
|
|
100
|
+
|
|
101
|
+
end_sha
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def tmux_state(tmux_session)
|
|
105
|
+
return "torn-down" if tmux_session.to_s.empty?
|
|
106
|
+
|
|
107
|
+
system("tmux", "has-session", "-t", tmux_session.to_s, out: File::NULL, err: File::NULL) ? "live" : "torn-down"
|
|
108
|
+
rescue StandardError
|
|
109
|
+
"torn-down"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|