harnex 0.6.5 → 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 +138 -0
- data/README.md +71 -21
- data/TECHNICAL.md +23 -0
- data/guides/01_dispatch.md +2 -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 +56 -230
- 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 +44 -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 +164 -23
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +2 -0
- metadata +6 -2
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "open3"
|
|
3
|
+
|
|
4
|
+
module Harnex
|
|
5
|
+
module Codex
|
|
6
|
+
module AppServer
|
|
7
|
+
# Extracted from Adapters::CodexAppServer per issue #41 Slice A.
|
|
8
|
+
# Pure move; behavior unchanged.
|
|
9
|
+
class Client
|
|
10
|
+
attr_reader :pid
|
|
11
|
+
|
|
12
|
+
def initialize(read_io:, write_io:, pid: nil)
|
|
13
|
+
@read_io = read_io
|
|
14
|
+
@write_io = write_io
|
|
15
|
+
@pid = pid
|
|
16
|
+
@next_id = 1
|
|
17
|
+
@pending = {}
|
|
18
|
+
@id_mutex = Mutex.new
|
|
19
|
+
@write_mutex = Mutex.new
|
|
20
|
+
@notification_handler = nil
|
|
21
|
+
@request_handler = nil
|
|
22
|
+
@disconnect_handler = nil
|
|
23
|
+
@disconnect_signaled = false
|
|
24
|
+
@closed = false
|
|
25
|
+
@reader_thread = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def on_notification(&block)
|
|
29
|
+
@notification_handler = block
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Handler for server-initiated requests (id + method). The block
|
|
33
|
+
# receives (method, params) and returns the response body for the
|
|
34
|
+
# JSON-RPC `result` field, or nil to reject with -32601.
|
|
35
|
+
def on_request(&block)
|
|
36
|
+
@request_handler = block
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_disconnect(&block)
|
|
40
|
+
@disconnect_handler = block
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def start
|
|
44
|
+
@reader_thread = Thread.new { read_loop }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def request(method, params = {})
|
|
48
|
+
raise "codex_appserver client is closed" if @closed
|
|
49
|
+
|
|
50
|
+
queue = Queue.new
|
|
51
|
+
id = @id_mutex.synchronize do
|
|
52
|
+
assigned = @next_id
|
|
53
|
+
@next_id += 1
|
|
54
|
+
@pending[assigned] = queue
|
|
55
|
+
assigned
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
write_line({ jsonrpc: "2.0", id: id, method: method, params: params })
|
|
59
|
+
result = queue.pop
|
|
60
|
+
raise result if result.is_a?(Exception)
|
|
61
|
+
|
|
62
|
+
result
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def notify(method, params = {})
|
|
66
|
+
return if @closed
|
|
67
|
+
|
|
68
|
+
write_line({ jsonrpc: "2.0", method: method, params: params })
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def close
|
|
72
|
+
return if @closed
|
|
73
|
+
|
|
74
|
+
@closed = true
|
|
75
|
+
|
|
76
|
+
@id_mutex.synchronize do
|
|
77
|
+
@pending.each_value { |q| q.push(StandardError.new("codex_appserver client closed")) }
|
|
78
|
+
@pending.clear
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
@write_io.close unless @write_io.closed?
|
|
83
|
+
rescue IOError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if @pid && process_alive?(@pid)
|
|
88
|
+
sleep 0.05
|
|
89
|
+
begin
|
|
90
|
+
Process.kill("TERM", @pid)
|
|
91
|
+
rescue Errno::ESRCH
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
@reader_thread&.join(2)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def terminate_process(term_grace_seconds:, kill_grace_seconds:)
|
|
100
|
+
return false unless @pid
|
|
101
|
+
|
|
102
|
+
begin
|
|
103
|
+
Process.kill("TERM", @pid)
|
|
104
|
+
rescue Errno::ESRCH
|
|
105
|
+
return true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
return true if wait_for_process_exit(@pid, term_grace_seconds)
|
|
109
|
+
|
|
110
|
+
begin
|
|
111
|
+
Process.kill("KILL", @pid)
|
|
112
|
+
rescue Errno::ESRCH
|
|
113
|
+
return true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
wait_for_process_exit(@pid, kill_grace_seconds)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Plan 30 Phase 2 — deployment-fallback subprocess restart.
|
|
120
|
+
#
|
|
121
|
+
# Spawns a `codex app-server` subprocess against a deployment_config
|
|
122
|
+
# and wraps it in a fresh Client. Caller is responsible for wiring
|
|
123
|
+
# handlers and running the JSON-RPC handshake — or use
|
|
124
|
+
# `spawn_with_fallback` for the full restart-with-resume flow.
|
|
125
|
+
def self.spawn(deployment_config:)
|
|
126
|
+
command = deployment_config[:command] || deployment_config["command"]
|
|
127
|
+
raise ArgumentError, "deployment_config requires :command" if command.nil? || Array(command).empty?
|
|
128
|
+
|
|
129
|
+
env = deployment_config[:env] || deployment_config["env"] || {}
|
|
130
|
+
cwd = deployment_config[:cwd] || deployment_config["cwd"]
|
|
131
|
+
|
|
132
|
+
opts = {}
|
|
133
|
+
opts[:chdir] = cwd if cwd
|
|
134
|
+
|
|
135
|
+
stdin_io, stdout_io, _stderr_io, wait_thr = Open3.popen3(env, *Array(command), **opts)
|
|
136
|
+
new(read_io: stdout_io, write_io: stdin_io, pid: wait_thr.pid)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Plan 30 Phase 2 — full subprocess restart for deployment fallback.
|
|
140
|
+
#
|
|
141
|
+
# Spawns a new subprocess against the alternate deployment, wires
|
|
142
|
+
# the supplied handlers, runs the initialize handshake, and resumes
|
|
143
|
+
# the prior threadId. Returns the started Client.
|
|
144
|
+
#
|
|
145
|
+
# Handlers are installed *before* the reader thread starts so no
|
|
146
|
+
# post-handshake notification is dropped.
|
|
147
|
+
def self.spawn_with_fallback(prior_thread_id:, deployment_config:, handshake_params:,
|
|
148
|
+
notification_handler: nil, request_handler: nil,
|
|
149
|
+
disconnect_handler: nil)
|
|
150
|
+
raise ArgumentError, "prior_thread_id required" if prior_thread_id.nil? || prior_thread_id.to_s.empty?
|
|
151
|
+
|
|
152
|
+
client = spawn(deployment_config: deployment_config)
|
|
153
|
+
client.on_notification(¬ification_handler) if notification_handler
|
|
154
|
+
client.on_request(&request_handler) if request_handler
|
|
155
|
+
client.on_disconnect(&disconnect_handler) if disconnect_handler
|
|
156
|
+
client.start
|
|
157
|
+
|
|
158
|
+
begin
|
|
159
|
+
client.request("initialize", handshake_params)
|
|
160
|
+
client.notify("initialized", {})
|
|
161
|
+
client.request("thread/resume", { threadId: prior_thread_id })
|
|
162
|
+
rescue StandardError
|
|
163
|
+
client.close
|
|
164
|
+
raise
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
client
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Plan 30 Phase 2 — graceful stop ahead of a deployment switch.
|
|
171
|
+
#
|
|
172
|
+
# Best-effort: issues `turn/interrupt` for any in-flight turn
|
|
173
|
+
# (bounded by interrupt_grace_seconds), drains pending RPC, and
|
|
174
|
+
# tears the subprocess down with the same TERM/KILL escalation
|
|
175
|
+
# used by `terminate_process`.
|
|
176
|
+
def stop_for_fallback(in_flight_turn: nil,
|
|
177
|
+
term_grace_seconds: 0.5,
|
|
178
|
+
kill_grace_seconds: 1.0,
|
|
179
|
+
interrupt_grace_seconds: 0.5)
|
|
180
|
+
if in_flight_turn && !@closed
|
|
181
|
+
interrupt_thread = Thread.new do
|
|
182
|
+
begin
|
|
183
|
+
request("turn/interrupt", in_flight_turn)
|
|
184
|
+
rescue StandardError
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
interrupt_thread.kill unless interrupt_thread.join(interrupt_grace_seconds)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
return true if @closed
|
|
192
|
+
|
|
193
|
+
@closed = true
|
|
194
|
+
|
|
195
|
+
fail_pending_requests(StandardError.new("codex_appserver client closed for fallback"))
|
|
196
|
+
|
|
197
|
+
begin
|
|
198
|
+
@write_io.close unless @write_io.closed?
|
|
199
|
+
rescue IOError
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
terminated =
|
|
204
|
+
if @pid
|
|
205
|
+
terminate_process(
|
|
206
|
+
term_grace_seconds: term_grace_seconds,
|
|
207
|
+
kill_grace_seconds: kill_grace_seconds
|
|
208
|
+
)
|
|
209
|
+
else
|
|
210
|
+
true
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
@reader_thread&.join(2)
|
|
214
|
+
terminated
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
private
|
|
218
|
+
|
|
219
|
+
def write_line(message)
|
|
220
|
+
@write_mutex.synchronize do
|
|
221
|
+
@write_io.write(JSON.generate(message))
|
|
222
|
+
@write_io.write("\n")
|
|
223
|
+
@write_io.flush
|
|
224
|
+
end
|
|
225
|
+
rescue Errno::EPIPE, IOError
|
|
226
|
+
signal_disconnect(nil)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def read_loop
|
|
230
|
+
buffer = +""
|
|
231
|
+
loop do
|
|
232
|
+
chunk = @read_io.readpartial(4096)
|
|
233
|
+
buffer << chunk
|
|
234
|
+
while (idx = buffer.index("\n"))
|
|
235
|
+
line = buffer.slice!(0, idx + 1).chomp
|
|
236
|
+
next if line.strip.empty?
|
|
237
|
+
|
|
238
|
+
handle_line(line)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
rescue EOFError, IOError, Errno::EIO
|
|
242
|
+
nil
|
|
243
|
+
ensure
|
|
244
|
+
signal_disconnect(nil)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def handle_line(line)
|
|
248
|
+
message = JSON.parse(line)
|
|
249
|
+
rescue JSON::ParserError => e
|
|
250
|
+
signal_disconnect(e)
|
|
251
|
+
return
|
|
252
|
+
else
|
|
253
|
+
dispatch_message(message)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def dispatch_message(message)
|
|
257
|
+
if message["id"] && message["method"]
|
|
258
|
+
handle_server_request(message)
|
|
259
|
+
return
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
if message.key?("id")
|
|
263
|
+
pending = @id_mutex.synchronize { @pending.delete(message["id"]) }
|
|
264
|
+
return unless pending
|
|
265
|
+
|
|
266
|
+
if message["error"]
|
|
267
|
+
err_msg = message.dig("error", "message") || "RPC error"
|
|
268
|
+
pending.push(StandardError.new("codex_appserver RPC error: #{err_msg}"))
|
|
269
|
+
signal_disconnect(message["error"])
|
|
270
|
+
else
|
|
271
|
+
pending.push(message["result"] || {})
|
|
272
|
+
end
|
|
273
|
+
return
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
@notification_handler&.call(message) if message["method"]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def handle_server_request(message)
|
|
280
|
+
result =
|
|
281
|
+
begin
|
|
282
|
+
@request_handler&.call(message["method"], message["params"] || {})
|
|
283
|
+
rescue StandardError
|
|
284
|
+
nil
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
if result.nil?
|
|
288
|
+
write_line({
|
|
289
|
+
jsonrpc: "2.0",
|
|
290
|
+
id: message["id"],
|
|
291
|
+
error: { code: -32601, message: "Unsupported server request: #{message['method']}" }
|
|
292
|
+
})
|
|
293
|
+
else
|
|
294
|
+
write_line({
|
|
295
|
+
jsonrpc: "2.0",
|
|
296
|
+
id: message["id"],
|
|
297
|
+
result: result
|
|
298
|
+
})
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def signal_disconnect(error)
|
|
303
|
+
return if @disconnect_signaled
|
|
304
|
+
|
|
305
|
+
@disconnect_signaled = true
|
|
306
|
+
fail_pending_requests(error)
|
|
307
|
+
@disconnect_handler&.call(error)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def fail_pending_requests(error)
|
|
311
|
+
exception =
|
|
312
|
+
if error.is_a?(Exception)
|
|
313
|
+
error
|
|
314
|
+
else
|
|
315
|
+
message = error.is_a?(Hash) ? error["message"] : nil
|
|
316
|
+
StandardError.new(message.to_s.empty? ? "codex_appserver disconnected" : message.to_s)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
@id_mutex.synchronize do
|
|
320
|
+
@pending.each_value { |queue| queue.push(exception) }
|
|
321
|
+
@pending.clear
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def process_alive?(pid)
|
|
326
|
+
Process.kill(0, pid)
|
|
327
|
+
true
|
|
328
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
329
|
+
false
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def wait_for_process_exit(pid, timeout_seconds)
|
|
333
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds.to_f
|
|
334
|
+
loop do
|
|
335
|
+
return true unless process_alive?(pid)
|
|
336
|
+
|
|
337
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
338
|
+
break if remaining <= 0
|
|
339
|
+
|
|
340
|
+
sleep([0.05, remaining].min)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
!process_alive?(pid)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "json"
|
|
2
|
+
require "optparse"
|
|
2
3
|
|
|
3
4
|
module Harnex
|
|
4
5
|
class Doctor
|
|
@@ -6,29 +7,40 @@ module Harnex
|
|
|
6
7
|
|
|
7
8
|
def self.usage
|
|
8
9
|
<<~TEXT
|
|
9
|
-
Usage: harnex doctor
|
|
10
|
+
Usage: harnex doctor [--sweep]
|
|
10
11
|
|
|
11
12
|
Runs preflight checks for harnex's adapter dependencies.
|
|
12
13
|
Currently verifies that Codex CLI is installed and at version
|
|
13
14
|
>= #{MIN_CODEX_VERSION} (required for the JSON-RPC `app-server`
|
|
14
15
|
adapter).
|
|
15
16
|
|
|
17
|
+
Options:
|
|
18
|
+
--sweep Include a read-only report of harnex/tmux session drift
|
|
19
|
+
-h, --help Show this help
|
|
20
|
+
|
|
16
21
|
Common patterns:
|
|
17
22
|
harnex doctor
|
|
23
|
+
harnex doctor --sweep
|
|
18
24
|
harnex doctor --help
|
|
19
25
|
|
|
20
26
|
Gotchas:
|
|
21
27
|
doctor validates local adapter prerequisites; it does not start sessions.
|
|
28
|
+
--sweep is diagnostic only; it does not stop sessions or remove files.
|
|
22
29
|
Run it after installing or upgrading Codex CLI.
|
|
23
30
|
TEXT
|
|
24
31
|
end
|
|
25
32
|
|
|
26
33
|
def initialize(argv = [])
|
|
27
34
|
@argv = argv.dup
|
|
35
|
+
@options = {
|
|
36
|
+
sweep: false,
|
|
37
|
+
help: false
|
|
38
|
+
}
|
|
28
39
|
end
|
|
29
40
|
|
|
30
41
|
def run
|
|
31
|
-
|
|
42
|
+
parser.parse!(@argv)
|
|
43
|
+
if @options[:help]
|
|
32
44
|
puts self.class.usage
|
|
33
45
|
return 0
|
|
34
46
|
end
|
|
@@ -38,12 +50,21 @@ module Harnex
|
|
|
38
50
|
ok: checks.all? { |c| c[:ok] },
|
|
39
51
|
checks: checks
|
|
40
52
|
}
|
|
53
|
+
summary[:sweep] = sweep_payload if @options[:sweep]
|
|
41
54
|
puts JSON.generate(summary)
|
|
42
55
|
summary[:ok] ? 0 : 1
|
|
43
56
|
end
|
|
44
57
|
|
|
45
58
|
private
|
|
46
59
|
|
|
60
|
+
def parser
|
|
61
|
+
@parser ||= OptionParser.new do |opts|
|
|
62
|
+
opts.banner = "Usage: harnex doctor [--sweep]"
|
|
63
|
+
opts.on("--sweep", "Include read-only session drift diagnostics") { @options[:sweep] = true }
|
|
64
|
+
opts.on("-h", "--help", "Show help") { @options[:help] = true }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
47
68
|
def check_codex
|
|
48
69
|
result = { name: "codex", required: ">= #{MIN_CODEX_VERSION}" }
|
|
49
70
|
|
|
@@ -75,6 +96,78 @@ module Harnex
|
|
|
75
96
|
[e.message, nil]
|
|
76
97
|
end
|
|
77
98
|
|
|
99
|
+
def sweep_payload
|
|
100
|
+
active, stale = read_session_registry
|
|
101
|
+
tmux_windows = read_tmux_windows
|
|
102
|
+
live_ids = active.map { |session| session["id"].to_s }.to_set
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
harnex_sessions: active,
|
|
106
|
+
tmux_windows_cx: tmux_windows,
|
|
107
|
+
orphan_tmux: tmux_windows.reject { |window| live_ids.include?(window[:session]) || live_ids.include?(window[:window]) },
|
|
108
|
+
stale_pid_files: stale
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def read_session_registry
|
|
113
|
+
return [[], []] unless Dir.exist?(Harnex::SESSIONS_DIR)
|
|
114
|
+
|
|
115
|
+
active = []
|
|
116
|
+
stale = []
|
|
117
|
+
|
|
118
|
+
Dir.glob(File.join(Harnex::SESSIONS_DIR, "*.json")).sort.each do |path|
|
|
119
|
+
data = JSON.parse(File.read(path)).merge("registry_path" => path)
|
|
120
|
+
if data["pid"] && Harnex.alive_pid?(data["pid"])
|
|
121
|
+
active << data
|
|
122
|
+
else
|
|
123
|
+
stale << stale_session_files(data, path)
|
|
124
|
+
end
|
|
125
|
+
rescue JSON::ParserError
|
|
126
|
+
stale << { registry_path: path, error: "invalid_json" }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
[active.sort_by { |session| [session["repo_root"].to_s, session["started_at"].to_s, session["id"].to_s] }.reverse, stale]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def stale_session_files(data, registry_path)
|
|
133
|
+
repo_root = data["repo_root"].to_s
|
|
134
|
+
id = data["id"].to_s
|
|
135
|
+
slug = repo_root.empty? || id.empty? ? nil : Harnex.session_file_slug(repo_root, id)
|
|
136
|
+
output_log_path = slug ? File.join(Harnex::STATE_DIR, "output", "#{slug}.log") : nil
|
|
137
|
+
events_log_path = slug ? File.join(Harnex::STATE_DIR, "events", "#{slug}.jsonl") : nil
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
id: id.empty? ? nil : id,
|
|
141
|
+
pid: data["pid"],
|
|
142
|
+
repo_root: repo_root.empty? ? nil : repo_root,
|
|
143
|
+
registry_path: registry_path,
|
|
144
|
+
output_log_path: file_or_nil(output_log_path),
|
|
145
|
+
events_log_path: file_or_nil(events_log_path)
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def read_tmux_windows
|
|
150
|
+
output, status = capture("tmux list-windows -a -F '\#{session_name}\t\#{window_name}\t\#{pane_pid}'")
|
|
151
|
+
return [] unless status&.success?
|
|
152
|
+
|
|
153
|
+
output.lines.filter_map do |line|
|
|
154
|
+
session, window, pid = line.chomp.split("\t", 3)
|
|
155
|
+
next unless session.to_s.start_with?("cx-") || window.to_s.start_with?("cx-")
|
|
156
|
+
|
|
157
|
+
{ session: session, window: window, pid: integer_or_nil(pid) }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def file_or_nil(path)
|
|
162
|
+
path if path && File.exist?(path)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def integer_or_nil(value)
|
|
166
|
+
Integer(value)
|
|
167
|
+
rescue ArgumentError, TypeError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
78
171
|
def parse_version(text)
|
|
79
172
|
match = text.match(/(\d+\.\d+\.\d+)/)
|
|
80
173
|
match ? Gem::Version.new(match[1]) : nil
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "optparse"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Harnex
|
|
6
|
+
class History
|
|
7
|
+
DEFAULT_LIMIT = 5
|
|
8
|
+
|
|
9
|
+
def self.usage(program_name = "harnex history")
|
|
10
|
+
<<~TEXT
|
|
11
|
+
Usage: #{program_name} [options]
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--limit N Number of records to show (default: #{DEFAULT_LIMIT})
|
|
15
|
+
--since DUR Only show records started within DUR (examples: 1h, 1d)
|
|
16
|
+
--id TEXT Filter records whose id contains TEXT
|
|
17
|
+
--global Read the global no-repo history file
|
|
18
|
+
--json Output JSONL instead of a table
|
|
19
|
+
--all Show all matching records
|
|
20
|
+
-h, --help Show this help
|
|
21
|
+
TEXT
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(argv)
|
|
25
|
+
@argv = argv.dup
|
|
26
|
+
@options = {
|
|
27
|
+
limit: DEFAULT_LIMIT,
|
|
28
|
+
since: nil,
|
|
29
|
+
id: nil,
|
|
30
|
+
global: false,
|
|
31
|
+
json: false,
|
|
32
|
+
all: false,
|
|
33
|
+
help: false
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run
|
|
38
|
+
parser.parse!(@argv)
|
|
39
|
+
if @options[:help]
|
|
40
|
+
puts self.class.usage
|
|
41
|
+
return 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
records = filtered_records
|
|
45
|
+
if @options[:json]
|
|
46
|
+
records.each { |record| puts JSON.generate(record) }
|
|
47
|
+
elsif !records.empty?
|
|
48
|
+
puts render_table(records)
|
|
49
|
+
end
|
|
50
|
+
0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def parser
|
|
56
|
+
@parser ||= OptionParser.new do |opts|
|
|
57
|
+
opts.banner = "Usage: harnex history [options]"
|
|
58
|
+
opts.on("--limit N", Integer, "Number of records to show") do |value|
|
|
59
|
+
raise OptionParser::InvalidArgument, "--limit must be greater than 0" if value <= 0
|
|
60
|
+
|
|
61
|
+
@options[:limit] = value
|
|
62
|
+
end
|
|
63
|
+
opts.on("--since DUR", "Only show records started within DUR") { |value| @options[:since] = parse_since(value) }
|
|
64
|
+
opts.on("--id TEXT", "Filter records by id substring") { |value| @options[:id] = value.to_s }
|
|
65
|
+
opts.on("--global", "Read the global no-repo history file") { @options[:global] = true }
|
|
66
|
+
opts.on("--json", "Output JSONL") { @options[:json] = true }
|
|
67
|
+
opts.on("--all", "Show all matching records") { @options[:all] = true }
|
|
68
|
+
opts.on("-h", "--help", "Show help") { @options[:help] = true }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_since(value)
|
|
73
|
+
Time.now - Harnex.parse_duration_seconds(value, option_name: "--since")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def filtered_records
|
|
77
|
+
records = load_records
|
|
78
|
+
records = records.select { |record| record["id"].to_s.include?(@options[:id]) } if @options[:id]
|
|
79
|
+
records = records.select { |record| started_after?(record, @options[:since]) } if @options[:since]
|
|
80
|
+
records = records.last(@options[:limit]) unless @options[:all]
|
|
81
|
+
records
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def load_records
|
|
85
|
+
path = DispatchHistory.path_for(Dir.pwd, global: @options[:global])
|
|
86
|
+
return [] unless File.file?(path)
|
|
87
|
+
|
|
88
|
+
File.readlines(path, chomp: true).filter_map do |line|
|
|
89
|
+
next if line.strip.empty?
|
|
90
|
+
|
|
91
|
+
JSON.parse(line)
|
|
92
|
+
rescue JSON::ParserError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def started_after?(record, floor)
|
|
98
|
+
Time.iso8601(record.fetch("started_at")) >= floor
|
|
99
|
+
rescue KeyError, ArgumentError
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def render_table(records)
|
|
104
|
+
columns = ["ID", "STARTED", "DURATION", "STATUS", "COMMIT", "TERMINAL", "TMUX"]
|
|
105
|
+
rows = records.map { |record| table_row(record) }
|
|
106
|
+
widths = columns.to_h { |column| [column, ([column.length] + rows.map { |row| row.fetch(column).length }).max] }
|
|
107
|
+
|
|
108
|
+
([columns.to_h { |column| [column, column] }] + rows)
|
|
109
|
+
.map { |row| columns.map { |column| row.fetch(column).ljust(widths.fetch(column)) }.join(" ") }
|
|
110
|
+
.join("\n")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def table_row(record)
|
|
114
|
+
{
|
|
115
|
+
"ID" => record["id"].to_s,
|
|
116
|
+
"STARTED" => format_started(record["started_at"]),
|
|
117
|
+
"DURATION" => format_duration(record["duration_s"]),
|
|
118
|
+
"STATUS" => record["status"].to_s,
|
|
119
|
+
"COMMIT" => short_commit(record["commit_sha"]),
|
|
120
|
+
"TERMINAL" => record["terminal_event"].to_s,
|
|
121
|
+
"TMUX" => record["tmux_state"].to_s
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def format_started(value)
|
|
126
|
+
Time.iso8601(value.to_s).getlocal.strftime("%Y-%m-%d %H:%M")
|
|
127
|
+
rescue ArgumentError
|
|
128
|
+
"-"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def format_duration(value)
|
|
132
|
+
seconds = Integer(value || 0)
|
|
133
|
+
hours = seconds / 3600
|
|
134
|
+
minutes = (seconds % 3600) / 60
|
|
135
|
+
rest = seconds % 60
|
|
136
|
+
return "#{hours}h#{minutes.to_s.rjust(2, '0')}m" if hours.positive?
|
|
137
|
+
return "#{minutes}m#{rest.to_s.rjust(2, '0')}s" if minutes.positive?
|
|
138
|
+
|
|
139
|
+
"#{rest}s"
|
|
140
|
+
rescue ArgumentError
|
|
141
|
+
"-"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def short_commit(value)
|
|
145
|
+
text = value.to_s
|
|
146
|
+
text.empty? ? "-" : text[0, 8]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|