harnex 0.7.14 → 0.9.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 +132 -1
- data/GUIDE.md +17 -10
- data/README.md +30 -23
- data/TECHNICAL.md +26 -5
- data/docs/codex-appserver.md +172 -0
- data/docs/configuration.md +111 -0
- data/docs/dispatch-telemetry.md +461 -0
- data/docs/events.md +133 -0
- data/guides/01_dispatch.md +6 -0
- data/guides/04_monitoring.md +50 -3
- 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/commands/doctor.rb +38 -4
- data/lib/harnex/commands/history.rb +56 -2
- data/lib/harnex/commands/run.rb +77 -7
- data/lib/harnex/commands/status.rb +43 -4
- data/lib/harnex/commands/wait.rb +158 -61
- data/lib/harnex/commands/watch.rb +3 -2
- data/lib/harnex/config.rb +166 -0
- data/lib/harnex/core.rb +11 -8
- data/lib/harnex/dispatch_history.rb +107 -3
- data/lib/harnex/pricing.rb +135 -0
- data/lib/harnex/retention.rb +311 -0
- data/lib/harnex/runtime/session.rb +156 -13
- data/lib/harnex/terminal_status.rb +12 -1
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- metadata +9 -2
data/lib/harnex/commands/wait.rb
CHANGED
|
@@ -14,6 +14,19 @@ module Harnex
|
|
|
14
14
|
EVENT_PREDICATES = %w[task_complete task_failed].freeze
|
|
15
15
|
LEGACY_EVENT_TYPES = %w[agent_state exited task_complete task_failed].freeze
|
|
16
16
|
|
|
17
|
+
# Exit-code contract for `--until done` (documented in guides/04_monitoring.md).
|
|
18
|
+
DONE_EXIT_DONE = 0
|
|
19
|
+
DONE_EXIT_FAILED = 1
|
|
20
|
+
DONE_EXIT_REJECTED_PROOF = 2
|
|
21
|
+
DONE_EXIT_NO_SESSION = 3
|
|
22
|
+
DONE_EXIT_TIMEOUT = 124
|
|
23
|
+
|
|
24
|
+
# Outcome classes where the process completed but the work was not
|
|
25
|
+
# accepted: proof was missing, invalid, rejected, or nothing happened.
|
|
26
|
+
REJECTED_PROOF_CLASSES = %w[
|
|
27
|
+
completed_no_activity report_missing report_invalid report_rejected
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
17
30
|
def self.usage(program_name = "harnex wait")
|
|
18
31
|
<<~TEXT
|
|
19
32
|
Usage: #{program_name} [options]
|
|
@@ -41,8 +54,18 @@ module Harnex
|
|
|
41
54
|
#{program_name} --id cx-i-42 --until prompt --timeout 120
|
|
42
55
|
#{program_name} --id cx-i-42
|
|
43
56
|
|
|
57
|
+
Exit codes for --until done:
|
|
58
|
+
0 completed with accepted work
|
|
59
|
+
1 failed (work failed, process failed, or killed)
|
|
60
|
+
2 completed but proof rejected (completed_no_activity,
|
|
61
|
+
report_missing, report_invalid, report_rejected)
|
|
62
|
+
3 no such session (no live, start, event, or terminal signal)
|
|
63
|
+
124 --timeout elapsed while the session was still running
|
|
64
|
+
|
|
44
65
|
Gotchas:
|
|
45
66
|
done is the safest work-level fence for monitors.
|
|
67
|
+
While the session's pid is alive, --until done blocks (up to
|
|
68
|
+
--timeout); "no signal yet" from a live worker is never terminal.
|
|
46
69
|
task_complete/task_failed are event predicates; prompt/busy are live state polls.
|
|
47
70
|
Prompt state alone does not prove work acceptance. Verify artifacts/tests.
|
|
48
71
|
Exit waits can resolve from terminal summary rows when live registry/
|
|
@@ -134,7 +157,9 @@ module Harnex
|
|
|
134
157
|
end
|
|
135
158
|
end
|
|
136
159
|
|
|
137
|
-
|
|
160
|
+
# min_ts: with a live session in view, events written before it started
|
|
161
|
+
# belong to an earlier run that reused the id — never match on them.
|
|
162
|
+
def scan_events(path, offset, predicate, task_complete_seen, start_time, min_ts: nil)
|
|
138
163
|
return [nil, offset, task_complete_seen] unless File.exist?(path) && File.size(path) > offset
|
|
139
164
|
|
|
140
165
|
File.open(path, "r") do |f|
|
|
@@ -142,6 +167,7 @@ module Harnex
|
|
|
142
167
|
f.each_line do |line|
|
|
143
168
|
event = parse_event(line)
|
|
144
169
|
next unless event
|
|
170
|
+
next if stale_event?(event, min_ts)
|
|
145
171
|
|
|
146
172
|
task_complete_seen = true if %w[task_complete task_failed].include?(event_type(event))
|
|
147
173
|
if matches?(event, predicate, task_complete_seen)
|
|
@@ -154,6 +180,17 @@ module Harnex
|
|
|
154
180
|
[nil, offset, task_complete_seen]
|
|
155
181
|
end
|
|
156
182
|
|
|
183
|
+
def stale_event?(event, min_ts)
|
|
184
|
+
return false unless min_ts
|
|
185
|
+
|
|
186
|
+
# Events without a parseable ts (legacy formats) cannot be dated;
|
|
187
|
+
# treat them as fresh rather than silently dropping completion signals.
|
|
188
|
+
ts = parse_wait_time(event["ts"])
|
|
189
|
+
return false unless ts
|
|
190
|
+
|
|
191
|
+
ts < min_ts
|
|
192
|
+
end
|
|
193
|
+
|
|
157
194
|
def parse_event(line)
|
|
158
195
|
event = JSON.parse(line)
|
|
159
196
|
event.is_a?(Hash) ? event : nil
|
|
@@ -206,11 +243,14 @@ module Harnex
|
|
|
206
243
|
seq: event["seq"],
|
|
207
244
|
waited_seconds: waited
|
|
208
245
|
}
|
|
246
|
+
exit_code = 0
|
|
209
247
|
if predicate == "done"
|
|
210
248
|
failed = done_event_failed?(event)
|
|
249
|
+
exit_code = failed ? done_failure_exit_code(event["outcome_class"]) : DONE_EXIT_DONE
|
|
211
250
|
payload.merge!(
|
|
212
251
|
ok: !failed,
|
|
213
252
|
status: failed ? "failed" : "done",
|
|
253
|
+
wait_result: done_wait_result(exit_code),
|
|
214
254
|
state: "running",
|
|
215
255
|
process_state: "running",
|
|
216
256
|
terminal: false,
|
|
@@ -224,79 +264,136 @@ module Harnex
|
|
|
224
264
|
payload[:last_error] = event["message"] || event["error"] if failed
|
|
225
265
|
end
|
|
226
266
|
puts JSON.generate(payload)
|
|
227
|
-
|
|
267
|
+
exit_code
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def done_failure_exit_code(outcome_class)
|
|
271
|
+
REJECTED_PROOF_CLASSES.include?(outcome_class.to_s) ? DONE_EXIT_REJECTED_PROOF : DONE_EXIT_FAILED
|
|
272
|
+
end
|
|
228
273
|
|
|
229
|
-
|
|
274
|
+
def done_wait_result(exit_code)
|
|
275
|
+
case exit_code
|
|
276
|
+
when DONE_EXIT_DONE then "done"
|
|
277
|
+
when DONE_EXIT_REJECTED_PROOF then "rejected_proof"
|
|
278
|
+
else "failed"
|
|
279
|
+
end
|
|
230
280
|
end
|
|
231
281
|
|
|
282
|
+
# Contract: while the session's pid is alive, block (up to --timeout).
|
|
283
|
+
# Liveness is re-checked every poll from the registry, falling back to an
|
|
284
|
+
# uncompleted dispatch-start row, so a running worker is never classified
|
|
285
|
+
# as dead just because one signal source is missing. "No signal yet"
|
|
286
|
+
# while alive is not terminal.
|
|
232
287
|
def wait_until_done
|
|
233
288
|
repo_root = Harnex.resolve_repo_root(@options[:repo_path])
|
|
234
289
|
events_path = Harnex.events_log_path(repo_root, @options[:id])
|
|
235
290
|
exit_path = Harnex.exit_status_path(repo_root, @options[:id])
|
|
236
|
-
registry = Harnex.read_registry(repo_root, @options[:id])
|
|
237
291
|
start_time = Time.now
|
|
238
292
|
deadline = @options[:timeout] ? start_time + @options[:timeout] : nil
|
|
239
293
|
|
|
240
294
|
offset = 0
|
|
241
295
|
task_complete_seen = false
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
status, offset, task_complete_seen = scan_events(events_path, offset, "done", task_complete_seen, start_time)
|
|
245
|
-
return status if status
|
|
296
|
+
observed_live = nil
|
|
297
|
+
session_started_at = nil
|
|
246
298
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
unless File.exist?(events_path)
|
|
253
|
-
warn("harnex wait: no session found with id #{@options[:id].inspect}")
|
|
254
|
-
puts JSON.generate(ok: false, id: @options[:id], state: "unknown", process_state: "unknown", terminal: false,
|
|
255
|
-
task_complete: false, done: false, work_state: "unknown", status: "unknown")
|
|
256
|
-
return 1
|
|
299
|
+
loop do
|
|
300
|
+
live = live_session(repo_root)
|
|
301
|
+
if live
|
|
302
|
+
observed_live = live
|
|
303
|
+
session_started_at ||= parse_wait_time(live["started_at"])
|
|
257
304
|
end
|
|
258
|
-
end
|
|
259
305
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
loop do
|
|
263
|
-
status, offset, task_complete_seen = scan_events(events_path, offset, "done", task_complete_seen, start_time)
|
|
306
|
+
status, offset, task_complete_seen =
|
|
307
|
+
scan_events(events_path, offset, "done", task_complete_seen, start_time, min_ts: session_started_at)
|
|
264
308
|
return status if status
|
|
265
309
|
|
|
266
|
-
unless
|
|
310
|
+
unless live
|
|
311
|
+
if observed_live
|
|
312
|
+
return resolve_after_exit(repo_root, events_path, exit_path, offset,
|
|
313
|
+
task_complete_seen, start_time, observed_live, session_started_at)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Historical query: never observed the session alive.
|
|
317
|
+
return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
|
|
318
|
+
|
|
267
319
|
terminal = done_status(repo_root)
|
|
268
320
|
return emit_done_terminal_status(terminal) if terminal
|
|
269
|
-
|
|
321
|
+
|
|
322
|
+
unless File.exist?(events_path)
|
|
323
|
+
warn("harnex wait: no session found with id #{@options[:id].inspect}")
|
|
324
|
+
puts JSON.generate(ok: false, id: @options[:id], status: "no_such_session",
|
|
325
|
+
wait_result: "no_such_session", state: "unknown", process_state: "unknown",
|
|
326
|
+
terminal: false, task_complete: false, done: false, work_state: "unknown")
|
|
327
|
+
return DONE_EXIT_NO_SESSION
|
|
328
|
+
end
|
|
270
329
|
end
|
|
271
330
|
|
|
272
331
|
if deadline && Time.now >= deadline
|
|
273
332
|
waited = (Time.now - start_time).round(1)
|
|
274
|
-
puts JSON.generate(ok: false, id: @options[:id], status: "timeout",
|
|
275
|
-
|
|
276
|
-
|
|
333
|
+
puts JSON.generate(ok: false, id: @options[:id], status: "timeout", wait_result: "timeout",
|
|
334
|
+
waited_seconds: waited, done: false,
|
|
335
|
+
work_state: live ? "running" : "unknown")
|
|
336
|
+
return DONE_EXIT_TIMEOUT
|
|
277
337
|
end
|
|
278
338
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
await_exit_status(exit_path)
|
|
283
|
-
return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
|
|
339
|
+
sleep EVENT_POLL_INTERVAL
|
|
340
|
+
end
|
|
341
|
+
end
|
|
284
342
|
|
|
285
|
-
|
|
286
|
-
|
|
343
|
+
# Registry row first; when it is not visible from this context, an
|
|
344
|
+
# uncompleted dispatch-start row with an alive pid still proves liveness.
|
|
345
|
+
def live_session(repo_root)
|
|
346
|
+
registry = Harnex.read_registry(repo_root, @options[:id])
|
|
347
|
+
return registry if registry
|
|
287
348
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
terminal: true, task_complete: false, done: false, work_state: "unknown",
|
|
291
|
-
waited_seconds: waited)
|
|
292
|
-
return 1
|
|
293
|
-
end
|
|
294
|
-
else
|
|
295
|
-
final_event_deadline = nil
|
|
296
|
-
end
|
|
349
|
+
Harnex::DispatchHistory.live_start_record(repo_root: repo_root, id: @options[:id])
|
|
350
|
+
end
|
|
297
351
|
|
|
298
|
-
|
|
352
|
+
# The session was alive and its pid is now gone: give teardown a bounded
|
|
353
|
+
# grace to land the final events, summary row, and exit-status file, then
|
|
354
|
+
# classify from the freshest signal that belongs to the observed session.
|
|
355
|
+
def resolve_after_exit(repo_root, events_path, exit_path, offset, task_complete_seen,
|
|
356
|
+
start_time, observed_live, session_started_at)
|
|
357
|
+
await_exit_status(exit_path)
|
|
358
|
+
|
|
359
|
+
status, _offset, _seen =
|
|
360
|
+
scan_events(events_path, offset, "done", task_complete_seen, start_time, min_ts: session_started_at)
|
|
361
|
+
return status if status
|
|
362
|
+
|
|
363
|
+
if File.exist?(exit_path) && fresh_exit_status?(exit_path, observed_live, session_started_at)
|
|
364
|
+
return emit_done_exit_status(exit_path, @options[:id])
|
|
299
365
|
end
|
|
366
|
+
|
|
367
|
+
terminal = done_status(repo_root, min_started_at: session_started_at)
|
|
368
|
+
return emit_done_terminal_status(terminal) if terminal
|
|
369
|
+
|
|
370
|
+
waited = (Time.now - start_time).round(1)
|
|
371
|
+
puts JSON.generate(ok: false, id: @options[:id], state: "exited", process_state: "exited",
|
|
372
|
+
terminal: true, task_complete: false, done: false, work_state: "unknown",
|
|
373
|
+
wait_result: "failed", waited_seconds: waited)
|
|
374
|
+
DONE_EXIT_FAILED
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Guards against classifying from an exit-status file left behind by an
|
|
378
|
+
# earlier dispatch that reused the same id.
|
|
379
|
+
def fresh_exit_status?(exit_path, observed_live, session_started_at)
|
|
380
|
+
data = JSON.parse(File.read(exit_path))
|
|
381
|
+
observed_session_id = observed_live["session_id"].to_s
|
|
382
|
+
exit_session_id = data["session_id"].to_s
|
|
383
|
+
return exit_session_id == observed_session_id unless exit_session_id.empty? || observed_session_id.empty?
|
|
384
|
+
|
|
385
|
+
exited_at = parse_wait_time(data["exited_at"])
|
|
386
|
+
return true unless exited_at && session_started_at
|
|
387
|
+
|
|
388
|
+
exited_at >= session_started_at
|
|
389
|
+
rescue StandardError
|
|
390
|
+
true
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def parse_wait_time(value)
|
|
394
|
+
Time.iso8601(value.to_s)
|
|
395
|
+
rescue ArgumentError
|
|
396
|
+
nil
|
|
300
397
|
end
|
|
301
398
|
|
|
302
399
|
def wait_until_state
|
|
@@ -441,11 +538,16 @@ module Harnex
|
|
|
441
538
|
status
|
|
442
539
|
end
|
|
443
540
|
|
|
444
|
-
def done_status(repo_root)
|
|
541
|
+
def done_status(repo_root, min_started_at: nil)
|
|
445
542
|
status = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: repo_root)
|
|
446
543
|
return nil unless status
|
|
447
544
|
return nil unless status["done"] || status["terminal"]
|
|
448
545
|
|
|
546
|
+
if min_started_at
|
|
547
|
+
row_started = parse_wait_time(status["started_at"])
|
|
548
|
+
return nil if row_started && row_started < min_started_at
|
|
549
|
+
end
|
|
550
|
+
|
|
449
551
|
status
|
|
450
552
|
end
|
|
451
553
|
|
|
@@ -457,6 +559,7 @@ module Harnex
|
|
|
457
559
|
exit_success = !task_failed && (exit_code.nil? || exit_code.to_i == 0)
|
|
458
560
|
state = exit_success ? "completed" : "failed"
|
|
459
561
|
done = task_complete || exit_success
|
|
562
|
+
result_code = done ? DONE_EXIT_DONE : done_failure_exit_code(data["outcome_class"])
|
|
460
563
|
payload = data.merge(
|
|
461
564
|
"ok" => done,
|
|
462
565
|
"id" => id,
|
|
@@ -466,32 +569,26 @@ module Harnex
|
|
|
466
569
|
"task_complete" => task_complete,
|
|
467
570
|
"task_failed" => task_failed,
|
|
468
571
|
"done" => done,
|
|
469
|
-
"work_state" => Harnex.work_state_for(state, task_complete: task_complete)
|
|
572
|
+
"work_state" => Harnex.work_state_for(state, task_complete: task_complete),
|
|
573
|
+
"wait_result" => done_wait_result(result_code)
|
|
470
574
|
)
|
|
471
|
-
success = done
|
|
472
575
|
puts JSON.generate(payload)
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
exit_code.is_a?(Integer) && exit_code.positive? ? exit_code : 1
|
|
576
|
+
result_code
|
|
476
577
|
rescue JSON::ParserError
|
|
477
578
|
puts JSON.generate(ok: false, id: id, state: "failed", process_state: "exited", terminal: true,
|
|
478
|
-
task_complete: false, done: false, work_state: "failed",
|
|
479
|
-
|
|
579
|
+
task_complete: false, done: false, work_state: "failed",
|
|
580
|
+
status: "invalid_exit_status", wait_result: "failed")
|
|
581
|
+
DONE_EXIT_FAILED
|
|
480
582
|
end
|
|
481
583
|
|
|
482
584
|
def emit_done_terminal_status(status)
|
|
483
585
|
payload = terminal_payload(status)
|
|
484
586
|
payload[:ok] = !!payload[:done]
|
|
485
587
|
payload[:status] = payload[:done] ? "done" : status["state"]
|
|
588
|
+
result_code = payload[:done] ? DONE_EXIT_DONE : done_failure_exit_code(status["outcome_class"])
|
|
589
|
+
payload[:wait_result] = done_wait_result(result_code)
|
|
486
590
|
puts JSON.generate(payload)
|
|
487
|
-
|
|
488
|
-
if payload[:ok]
|
|
489
|
-
0
|
|
490
|
-
elsif status["exit_code"].is_a?(Integer) && status["exit_code"] > 0
|
|
491
|
-
status["exit_code"]
|
|
492
|
-
else
|
|
493
|
-
1
|
|
494
|
-
end
|
|
591
|
+
result_code
|
|
495
592
|
end
|
|
496
593
|
|
|
497
594
|
def emit_terminal_status(status)
|
|
@@ -350,8 +350,9 @@ module Harnex
|
|
|
350
350
|
-h, --help Show this help
|
|
351
351
|
|
|
352
352
|
`harnex watch` is the safe watcher for existing --tmux or detached
|
|
353
|
-
dispatches.
|
|
354
|
-
|
|
353
|
+
dispatches. Exit codes follow the `harnex wait --until done` contract:
|
|
354
|
+
0 accepted work, 1 failed, 2 completed-but-proof-rejected, 3 no such
|
|
355
|
+
session, 124 for --max-wait timeouts.
|
|
355
356
|
|
|
356
357
|
For launch-and-babysit stall recovery, use `harnex run --watch`.
|
|
357
358
|
TEXT
|
|
@@ -0,0 +1,166 @@
|
|
|
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].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
|
+
}.freeze
|
|
19
|
+
DEFAULT_RETENTION_LIMITS = RETENTION_DIRS.to_h do |dir|
|
|
20
|
+
[dir, { "max_age_days" => 45, "max_bytes" => 1_073_741_824 }]
|
|
21
|
+
end.freeze
|
|
22
|
+
|
|
23
|
+
ConfigError = Class.new(StandardError)
|
|
24
|
+
|
|
25
|
+
RepoConfig = Struct.new(:root, :path, :data, keyword_init: true) do
|
|
26
|
+
def present?
|
|
27
|
+
!path.nil? && File.file?(path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def phase
|
|
31
|
+
data.is_a?(Hash) ? data["phase"] : nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def retention
|
|
35
|
+
data.is_a?(Hash) ? data["retention"] : nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def load_repo(start_path)
|
|
42
|
+
root = DispatchHistory.find_git_root(start_path)
|
|
43
|
+
return RepoConfig.new(root: nil, path: nil, data: {}) unless root
|
|
44
|
+
|
|
45
|
+
path = File.join(root, CONFIG_RELATIVE_PATH)
|
|
46
|
+
return RepoConfig.new(root: root, path: path, data: {}) unless File.file?(path)
|
|
47
|
+
|
|
48
|
+
parsed = JSON.parse(File.read(path))
|
|
49
|
+
validate_document!(parsed, path)
|
|
50
|
+
RepoConfig.new(root: root, path: path, data: parsed)
|
|
51
|
+
rescue JSON::ParserError => e
|
|
52
|
+
raise ConfigError, "#{path}: malformed JSON: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_document!(document, path)
|
|
56
|
+
unless document.is_a?(Hash)
|
|
57
|
+
raise ConfigError, "#{path}: config must be a JSON object"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
validate_phase!(document["phase"], path) if document.key?("phase")
|
|
61
|
+
validate_retention!(document["retention"], path) if document.key?("retention")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_phase!(phase, path)
|
|
65
|
+
unless phase.is_a?(Hash)
|
|
66
|
+
raise ConfigError, "#{path}: $.phase must be an object"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
allowlist = phase["allowlist"]
|
|
70
|
+
unless allowlist.is_a?(Array)
|
|
71
|
+
raise ConfigError, "#{path}: $.phase.allowlist must be an array"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
allowlist.each_with_index do |entry, index|
|
|
75
|
+
next if entry.is_a?(String) && !entry.strip.empty?
|
|
76
|
+
|
|
77
|
+
raise ConfigError,
|
|
78
|
+
"#{path}: $.phase.allowlist[#{index}] must be a non-empty string"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
policy = phase["policy"]
|
|
82
|
+
unless PHASE_POLICIES.include?(policy)
|
|
83
|
+
raise ConfigError,
|
|
84
|
+
"#{path}: $.phase.policy must be one of #{PHASE_POLICIES.join(', ')}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def retention_limits(start_path = Dir.pwd, env: ENV, config: nil)
|
|
89
|
+
config ||= load_repo(start_path)
|
|
90
|
+
limits = deep_dup_retention_defaults
|
|
91
|
+
retention = config.retention
|
|
92
|
+
|
|
93
|
+
if retention
|
|
94
|
+
RETENTION_DIRS.each do |dir|
|
|
95
|
+
next unless retention.key?(dir)
|
|
96
|
+
|
|
97
|
+
RETENTION_FIELDS.each do |field|
|
|
98
|
+
next unless retention.fetch(dir).key?(field)
|
|
99
|
+
|
|
100
|
+
limits.fetch(dir)[field] = parse_positive_integer!(
|
|
101
|
+
retention.fetch(dir).fetch(field),
|
|
102
|
+
"#{config.path}: $.retention.#{dir}.#{field}"
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
RETENTION_ENV.each do |dir, fields|
|
|
109
|
+
fields.each do |field, name|
|
|
110
|
+
next unless env.key?(name)
|
|
111
|
+
|
|
112
|
+
limits.fetch(dir)[field] = parse_positive_integer!(
|
|
113
|
+
env.fetch(name),
|
|
114
|
+
name
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
limits
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def validate_retention!(retention, path)
|
|
123
|
+
unless retention.is_a?(Hash)
|
|
124
|
+
raise ConfigError, "#{path}: $.retention must be an object"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
retention.each do |dir, value|
|
|
128
|
+
unless RETENTION_DIRS.include?(dir)
|
|
129
|
+
raise ConfigError,
|
|
130
|
+
"#{path}: $.retention.#{dir} is not supported (expected events or output)"
|
|
131
|
+
end
|
|
132
|
+
unless value.is_a?(Hash)
|
|
133
|
+
raise ConfigError, "#{path}: $.retention.#{dir} must be an object"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
value.each do |field, limit|
|
|
137
|
+
unless RETENTION_FIELDS.include?(field)
|
|
138
|
+
raise ConfigError,
|
|
139
|
+
"#{path}: $.retention.#{dir}.#{field} is not supported"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
parse_positive_integer!(
|
|
143
|
+
limit,
|
|
144
|
+
"#{path}: $.retention.#{dir}.#{field}"
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def parse_positive_integer!(value, label)
|
|
151
|
+
text = value.to_s.strip
|
|
152
|
+
unless text.match?(/\A[0-9]+\z/)
|
|
153
|
+
raise ConfigError, "#{label} must be a positive integer"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
integer = Integer(text)
|
|
157
|
+
raise ConfigError, "#{label} must be a positive integer" if integer <= 0
|
|
158
|
+
|
|
159
|
+
integer
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def deep_dup_retention_defaults
|
|
163
|
+
DEFAULT_RETENTION_LIMITS.transform_values(&:dup)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/harnex/core.rb
CHANGED
|
@@ -114,13 +114,6 @@ module Harnex
|
|
|
114
114
|
text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
-
def default_summary_out_path(repo_root)
|
|
118
|
-
root = repo_root.to_s
|
|
119
|
-
return nil if root.empty?
|
|
120
|
-
|
|
121
|
-
File.join(root, ".harnex", "dispatch.jsonl")
|
|
122
|
-
end
|
|
123
|
-
|
|
124
117
|
def git_capture_start(repo_root)
|
|
125
118
|
sha = git_output(repo_root, "rev-parse", "HEAD")
|
|
126
119
|
branch = git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
@@ -157,8 +150,18 @@ module Harnex
|
|
|
157
150
|
{}
|
|
158
151
|
end
|
|
159
152
|
|
|
153
|
+
# Canonical path resolution shared by every registry/exit/events writer and
|
|
154
|
+
# reader. realpath collapses symlinked prefixes so a session registered from
|
|
155
|
+
# a symlinked cwd stays visible to checkers resolving the physical path.
|
|
156
|
+
def canonical_repo_root(path)
|
|
157
|
+
expanded = File.expand_path(path)
|
|
158
|
+
File.realpath(expanded)
|
|
159
|
+
rescue StandardError
|
|
160
|
+
expanded
|
|
161
|
+
end
|
|
162
|
+
|
|
160
163
|
def repo_key(repo_root)
|
|
161
|
-
Digest::SHA256.hexdigest(repo_root)[0, 16]
|
|
164
|
+
Digest::SHA256.hexdigest(canonical_repo_root(repo_root))[0, 16]
|
|
162
165
|
end
|
|
163
166
|
|
|
164
167
|
def normalize_id(id)
|
|
@@ -9,6 +9,12 @@ module Harnex
|
|
|
9
9
|
|
|
10
10
|
MAX_REPO_WALK_LEVELS = 10
|
|
11
11
|
|
|
12
|
+
# v2 marks the unified era: one rich dispatch_end row per dispatch
|
|
13
|
+
# carrying both the thin envelope and the summary sections. Readers
|
|
14
|
+
# key on record_type, not this stamp; legacy clauses keep accepting
|
|
15
|
+
# v1 and envelope-less rows mixed in the same file.
|
|
16
|
+
SCHEMA_VERSION = 2
|
|
17
|
+
|
|
12
18
|
def global_path
|
|
13
19
|
File.join(STATE_DIR, "dispatch.jsonl")
|
|
14
20
|
end
|
|
@@ -62,12 +68,111 @@ module Harnex
|
|
|
62
68
|
end
|
|
63
69
|
end
|
|
64
70
|
|
|
71
|
+
def start_record?(record)
|
|
72
|
+
record.is_a?(Hash) && record["record_type"] == "dispatch_start"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def end_record?(record)
|
|
76
|
+
return false unless record.is_a?(Hash)
|
|
77
|
+
return true if record["record_type"] == "dispatch_end"
|
|
78
|
+
|
|
79
|
+
# Legacy end rows predate record_type.
|
|
80
|
+
record["schema_version"] == 1 && record.key?("status") && !record.key?("record_type")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def end_matches_start?(end_record, start_record)
|
|
84
|
+
end_session = end_record["session_id"].to_s
|
|
85
|
+
start_session = start_record["session_id"].to_s
|
|
86
|
+
return end_session == start_session unless end_session.empty? || start_session.empty?
|
|
87
|
+
|
|
88
|
+
end_record["id"].to_s == start_record["id"].to_s &&
|
|
89
|
+
end_record["started_at"].to_s == start_record["started_at"].to_s
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Latest start row for `id` and any end row that completes it.
|
|
93
|
+
def latest_rows(path, id)
|
|
94
|
+
latest_start = nil
|
|
95
|
+
matching_end = nil
|
|
96
|
+
|
|
97
|
+
return { start: nil, end: nil } unless File.file?(path)
|
|
98
|
+
|
|
99
|
+
File.foreach(path) do |line|
|
|
100
|
+
record = JSON.parse(line)
|
|
101
|
+
next unless record.is_a?(Hash)
|
|
102
|
+
next unless record["id"].to_s == id
|
|
103
|
+
|
|
104
|
+
if start_record?(record)
|
|
105
|
+
latest_start = record
|
|
106
|
+
matching_end = nil
|
|
107
|
+
elsif end_record?(record)
|
|
108
|
+
matching_end = record if latest_start && end_matches_start?(record, latest_start)
|
|
109
|
+
end
|
|
110
|
+
rescue JSON::ParserError
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
{ start: latest_start, end: matching_end }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A dispatch_start row with no completing end row whose pid is alive on
|
|
118
|
+
# this host — evidence of a running session even when the live registry
|
|
119
|
+
# is not visible from the caller's context.
|
|
120
|
+
def live_start_record(repo_root:, id:)
|
|
121
|
+
normalized_id = Harnex.normalize_id(id)
|
|
122
|
+
rows = latest_rows(path_for(repo_root), normalized_id)
|
|
123
|
+
start = rows[:start]
|
|
124
|
+
return nil unless start
|
|
125
|
+
return nil if rows[:end]
|
|
126
|
+
return nil unless same_host?(start)
|
|
127
|
+
|
|
128
|
+
pid = start["pid"]
|
|
129
|
+
return nil unless pid && Harnex.alive_pid?(pid)
|
|
130
|
+
|
|
131
|
+
start
|
|
132
|
+
rescue StandardError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def same_host?(record)
|
|
137
|
+
host = record["host"].to_s
|
|
138
|
+
return true if host.empty?
|
|
139
|
+
|
|
140
|
+
host == Harnex.host_info[:host].to_s
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Appended at registration so a running dispatch always has a durable
|
|
144
|
+
# trace; the dispatch_end row written in finalize_session! completes it.
|
|
145
|
+
def build_start_record(session)
|
|
146
|
+
{
|
|
147
|
+
schema_version: SCHEMA_VERSION,
|
|
148
|
+
record_type: "dispatch_start",
|
|
149
|
+
id: session.id,
|
|
150
|
+
session_id: session.session_id,
|
|
151
|
+
pid: session.pid,
|
|
152
|
+
host: Harnex.host_info[:host],
|
|
153
|
+
cli: session.adapter.key,
|
|
154
|
+
description: session.description,
|
|
155
|
+
started_at: session.started_at.utc.iso8601,
|
|
156
|
+
repo_root: session.repo_root,
|
|
157
|
+
tier: session.__send__(:meta_hash)["tier"],
|
|
158
|
+
meta: session.__send__(:meta_hash),
|
|
159
|
+
summary_out_path: session.summary_out,
|
|
160
|
+
events_log_path: session.events_log_path
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The v2 end row: the thin envelope merged with the rich summary
|
|
165
|
+
# sections. The envelope carries no raw meta passthrough — the summary's
|
|
166
|
+
# meta section (a superset with provenance) rides in its place; top-level
|
|
167
|
+
# tier stays for the history renderer.
|
|
65
168
|
def build_record(session)
|
|
66
169
|
ended_at = session.ended_at || Time.now
|
|
67
170
|
status, terminal_event = classify(session)
|
|
68
171
|
{
|
|
69
|
-
schema_version:
|
|
172
|
+
schema_version: SCHEMA_VERSION,
|
|
173
|
+
record_type: "dispatch_end",
|
|
70
174
|
id: session.id,
|
|
175
|
+
session_id: session.session_id,
|
|
71
176
|
description: session.description,
|
|
72
177
|
cli: session.adapter.key,
|
|
73
178
|
started_at: session.started_at.utc.iso8601,
|
|
@@ -77,11 +182,10 @@ module Harnex
|
|
|
77
182
|
terminal_event: terminal_event,
|
|
78
183
|
commit_sha: commit_sha(session.git_start, session.git_end),
|
|
79
184
|
tier: session.__send__(:meta_hash)["tier"],
|
|
80
|
-
meta: session.__send__(:meta_hash),
|
|
81
185
|
summary_out_path: session.summary_out,
|
|
82
186
|
events_log_path: session.events_log_path,
|
|
83
187
|
tmux_state: tmux_state(session.__send__(:summary_tmux_session))
|
|
84
|
-
}
|
|
188
|
+
}.merge(session.__send__(:build_summary_record))
|
|
85
189
|
end
|
|
86
190
|
|
|
87
191
|
def classify(session)
|