omnilane 0.7.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.
@@ -0,0 +1,802 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ # omnilane background-job helper.
4
+ # Usage: jobs.sh [--json] list | status JOB_ID | result JOB_ID | stats [--last N]
5
+ # jobs.sh wait JOB_ID [--timeout N]
6
+ # jobs.sh audit [--last N] [--json] | prune [--keep N] [--apply]
7
+
8
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
9
+ JOBS="$OMNILANE_HOME/jobs"
10
+ JOB_ID_PATTERN='^[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+$'
11
+ JSON_MODE=0
12
+ COMMAND="unknown"
13
+
14
+ json_escape() {
15
+ local s="$1" out="" ch escaped code i
16
+ for ((i = 0; i < ${#s}; i++)); do
17
+ ch="${s:i:1}"
18
+ case "$ch" in
19
+ '"') out="$out\\\"" ;;
20
+ '\\') out="$out\\\\" ;;
21
+ $'\b') out="$out\\b" ;;
22
+ $'\f') out="$out\\f" ;;
23
+ $'\n') out="$out\\n" ;;
24
+ $'\r') out="$out\\r" ;;
25
+ $'\t') out="$out\\t" ;;
26
+ *)
27
+ LC_CTYPE=C printf -v code '%d' "'$ch"
28
+ if [[ "$code" -ge 0 && "$code" -lt 32 ]]; then
29
+ printf -v escaped '\\u%04x' "$code"
30
+ out="$out$escaped"
31
+ else
32
+ out="$out$ch"
33
+ fi
34
+ ;;
35
+ esac
36
+ done
37
+ printf '%s' "$out"
38
+ }
39
+
40
+ die() {
41
+ local rc="$1" message="$2"
42
+ if [[ "$JSON_MODE" -eq 1 ]]; then
43
+ printf '{"schema_version":1,"command":"%s","ok":false,"error":"%s"}\n' \
44
+ "$(json_escape "$COMMAND")" "$(json_escape "$message")"
45
+ else
46
+ printf '%s\n' "$message" >&2
47
+ fi
48
+ exit "$rc"
49
+ }
50
+
51
+ USAGE_TEXT="usage: jobs.sh [--json] list|status ID|result ID|tail ID [--lines N]|retry ID [--background]|stats [--last N]|wait ID [--timeout N]|audit [--last N]|prune [--keep N] [--older-than DAYS] [--apply]|help"
52
+
53
+ usage() {
54
+ die 2 "$USAGE_TEXT"
55
+ }
56
+
57
+ validate_job_store() {
58
+ if [[ -L "$JOBS" || ( -e "$JOBS" && ! -d "$JOBS" ) ]]; then
59
+ die 1 "unsafe jobs store path (want a real directory)"
60
+ fi
61
+ }
62
+
63
+ select_job() {
64
+ local id="${1:-}"
65
+ [[ -n "$id" ]] || usage
66
+ [[ "$id" =~ $JOB_ID_PATTERN ]] || {
67
+ die 2 "invalid job id"
68
+ }
69
+ JOB_DIR="$JOBS/$id"
70
+ # A job must be a real child directory, never a symlink outside the store.
71
+ [[ -d "$JOB_DIR" && ! -L "$JOB_DIR" ]] || {
72
+ die 1 "no such job"
73
+ }
74
+ }
75
+
76
+ read_exit_code() {
77
+ local path="$1" size value length
78
+ RECORDED_EXIT=""
79
+ [[ -f "$path" && ! -L "$path" ]] || return 1
80
+ size="$(LC_ALL=C wc -c < "$path" | tr -d '[:space:]')" || return 2
81
+ [[ "$size" =~ ^[0-9]+$ && "$size" -le 4 ]] || return 2
82
+ value="$(cat "$path")" || return 2
83
+ length="${#value}"
84
+ # Accept digits with either no terminator or exactly one trailing newline.
85
+ [[ "$size" -eq "$length" || "$size" -eq $((length + 1)) ]] || return 2
86
+ [[ "$value" =~ ^(0|[1-9][0-9]{0,2})$ && "$value" -le 255 ]] || return 2
87
+ RECORDED_EXIT="$value"
88
+ }
89
+
90
+ read_public_metadata() {
91
+ local path="$1" size value length
92
+ local LC_ALL=C
93
+ PUBLIC_METADATA=""
94
+ [[ -f "$path" && ! -L "$path" ]] || return 1
95
+ size="$(wc -c < "$path" | tr -d '[:space:]')" || return 2
96
+ [[ "$size" =~ ^[0-9]+$ && "$size" -le 4096 ]] || return 2
97
+ value="$(cat "$path")" || return 2
98
+ length="${#value}"
99
+ [[ "$length" -gt 0 ]] || return 2
100
+ # Metadata is generated as one JSON line. Reject extra lines and terminal
101
+ # controls instead of echoing corrupted local state to the user's terminal.
102
+ [[ "$size" -eq "$length" || "$size" -eq $((length + 1)) ]] || return 2
103
+ [[ "$value" != *$'\n'* ]] || return 2
104
+ if printf '%s' "$value" | grep -q '[[:cntrl:]]'; then
105
+ return 2
106
+ fi
107
+ PUBLIC_METADATA="$value"
108
+ }
109
+
110
+ valid_utf8() {
111
+ command -v iconv >/dev/null 2>&1 || return 1
112
+ printf '%s' "$1" | iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1
113
+ }
114
+
115
+ read_job_pid() {
116
+ local path="$1" size value length
117
+ local LC_ALL=C
118
+ RECORDED_PID=""
119
+ [[ -f "$path" && ! -L "$path" ]] || return 1
120
+ # 2>/dev/null before < : the file can vanish between the check above and
121
+ # these reads (a job being removed mid-wait). Redirections apply left to
122
+ # right, so stderr is already silenced when the open fails; the failure is
123
+ # classified by the caller and the noise must not leak into captured output.
124
+ size="$(wc -c 2>/dev/null < "$path" | tr -d '[:space:]')" || return 2
125
+ [[ "$size" =~ ^[0-9]+$ && "$size" -le 11 ]] || return 2
126
+ value="$(cat "$path" 2>/dev/null)" || return 2
127
+ length="${#value}"
128
+ [[ "$size" -eq "$length" || "$size" -eq $((length + 1)) ]] || return 2
129
+ [[ "$value" =~ ^[1-9][0-9]{0,9}$ ]] || return 2
130
+ RECORDED_PID="$value"
131
+ }
132
+
133
+ parse_stats_metadata() {
134
+ local value="$1"
135
+ local metadata_re='^\{"lane":"([a-z][a-z0-9-]*)","vendor":"(codex|claude|grok|gemini|exec)"(,|})'
136
+ STATS_LANE=""
137
+ STATS_VENDOR=""
138
+ [[ "$value" =~ $metadata_re ]] || return 1
139
+ STATS_LANE="${BASH_REMATCH[1]}"
140
+ STATS_VENDOR="${BASH_REMATCH[2]}"
141
+ }
142
+
143
+ parse_audit_metadata() {
144
+ local value="$1" metadata_re
145
+ local json_string='([^"\\]|\\["\\/bfnrt]|\\u[0-9a-fA-F]{4})*'
146
+ metadata_re="^\\{\"lane\":\"[a-z][a-z0-9-]*\",\"vendor\":\"(codex|claude|grok|gemini|exec)\",\"model\":\"${json_string}\",\"effort\":\"${json_string}\",\"timeout\":(0|[1-9][0-9]*),\"job_timeout\":(null|0|[1-9][0-9]*),\"mode\":\"(advise|work)\",\"workdir\":\"${json_string}\",\"candidate\":\"[1-9][0-9]*/[1-9][0-9]*\",\"started\":\"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\"\\}$"
147
+ [[ "$value" =~ $metadata_re ]]
148
+ }
149
+
150
+ print_stat_counts() {
151
+ local label="$1"
152
+ shift
153
+ [[ $# -gt 0 ]] || return 0
154
+ printf '%s\n' "$@" | LC_ALL=C sort | uniq -c | while read -r count value; do
155
+ printf '%s %s %s\n' "$label" "$value" "$count"
156
+ done
157
+ }
158
+
159
+ json_stat_counts() {
160
+ local first=1 value count
161
+ [[ $# -gt 0 ]] || { printf '[]'; return 0; }
162
+ printf '['
163
+ while read -r count value; do
164
+ [[ "$first" -eq 1 ]] || printf ','
165
+ first=0
166
+ printf '{"name":"%s","count":%s}' "$(json_escape "$value")" "$count"
167
+ done < <(printf '%s\n' "$@" | LC_ALL=C sort | uniq -c)
168
+ printf ']'
169
+ }
170
+
171
+ path_mode() {
172
+ if stat -f '%Lp' "$1" >/dev/null 2>&1; then
173
+ stat -f '%Lp' "$1"
174
+ else
175
+ stat -c '%a' "$1"
176
+ fi
177
+ }
178
+
179
+ args=()
180
+ for arg in "$@"; do
181
+ if [[ "$arg" == "--json" ]]; then
182
+ [[ "$JSON_MODE" -eq 0 ]] || usage
183
+ JSON_MODE=1
184
+ else
185
+ args+=("$arg")
186
+ fi
187
+ done
188
+ # ${args[@]} with zero remaining arguments is an unbound-variable error under
189
+ # set -u on Bash 3.2; a bare `jobs.sh` must reach usage, not crash.
190
+ set -- ${args[@]+"${args[@]}"}
191
+ COMMAND="${1:-unknown}"
192
+ if [[ "$JSON_MODE" -eq 1 ]]; then
193
+ case "$COMMAND" in
194
+ list|status|result|stats|audit) ;;
195
+ *) usage ;;
196
+ esac
197
+ fi
198
+
199
+ validate_job_store
200
+
201
+ case "${1:-}" in
202
+ help|--help|-h)
203
+ [[ "$JSON_MODE" -eq 0 && $# -eq 1 ]] || usage
204
+ echo "$USAGE_TEXT" ;;
205
+ tail)
206
+ # Peek at the live public output stream of one job (running or done)
207
+ # without touching its exit contract. Only out.txt is shown; stderr and
208
+ # worker logs stay private to `result` and the job directory.
209
+ [[ "$JSON_MODE" -eq 0 && $# -ge 2 ]] || usage
210
+ tail_id="$2"
211
+ lines=20
212
+ shift 2
213
+ while [[ $# -gt 0 ]]; do
214
+ case "$1" in
215
+ --lines)
216
+ [[ $# -ge 2 ]] || usage
217
+ lines="$2"; shift 2 ;;
218
+ *) usage ;;
219
+ esac
220
+ done
221
+ [[ "$lines" =~ ^([1-9][0-9]{0,2}|1000)$ ]] || {
222
+ die 2 "invalid --lines value (want 1..1000)"
223
+ }
224
+ select_job "$tail_id"
225
+ out_path="$JOB_DIR/out.txt"
226
+ if [[ -L "$out_path" ]]; then
227
+ die 1 "unsafe output path (symlink)"
228
+ fi
229
+ if [[ ! -f "$out_path" ]]; then
230
+ die 1 "no output yet"
231
+ fi
232
+ tail -n "$lines" -- "$out_path" ;;
233
+ retry)
234
+ # Re-dispatch a COMPLETED job with its recorded route and original task
235
+ # text. Metadata parsing is fail-closed: any value that needed JSON
236
+ # escaping does not match the strict patterns below and refuses to retry,
237
+ # so corrupted or hand-edited metadata can never smuggle flags or paths.
238
+ [[ "$JSON_MODE" -eq 0 && $# -ge 2 ]] || usage
239
+ retry_id="$2"
240
+ retry_background=0
241
+ shift 2
242
+ while [[ $# -gt 0 ]]; do
243
+ case "$1" in
244
+ --background) retry_background=1; shift ;;
245
+ *) usage ;;
246
+ esac
247
+ done
248
+ select_job "$retry_id"
249
+ read_exit_code "$JOB_DIR/exit" || {
250
+ die 1 "retry needs a completed job (still running, or invalid exit metadata)"
251
+ }
252
+ read_public_metadata "$JOB_DIR/meta.json" || {
253
+ die 1 "cannot retry: unreadable job metadata"
254
+ }
255
+ retry_re='^\{"lane":"([a-z][a-z0-9-]*)","vendor":"(codex|claude|grok|gemini|exec)","model":"([^"\\]*)","effort":"([^"\\]*)","timeout":([1-9][0-9]*),"job_timeout":([1-9][0-9]*|null),"mode":"(advise|work)","workdir":"([^"\\]*)",'
256
+ [[ "$PUBLIC_METADATA" =~ $retry_re ]] || {
257
+ die 1 "cannot retry: job metadata is not safely parseable"
258
+ }
259
+ retry_lane="${BASH_REMATCH[1]}"
260
+ retry_vendor="${BASH_REMATCH[2]}"
261
+ retry_model="${BASH_REMATCH[3]}"
262
+ retry_effort="${BASH_REMATCH[4]}"
263
+ retry_timeout="${BASH_REMATCH[5]}"
264
+ retry_job_timeout="${BASH_REMATCH[6]}"
265
+ retry_mode="${BASH_REMATCH[7]}"
266
+ retry_workdir="${BASH_REMATCH[8]}"
267
+ task_path="$JOB_DIR/task.txt"
268
+ [[ -f "$task_path" && ! -L "$task_path" ]] || {
269
+ die 1 "cannot retry: original task text is missing"
270
+ }
271
+ [[ -d "$retry_workdir" ]] || {
272
+ die 1 "cannot retry: original workdir no longer exists: $retry_workdir"
273
+ }
274
+ retry_args=(--mode "$retry_mode" --workdir "$retry_workdir" --timeout "$retry_timeout")
275
+ [[ "$retry_job_timeout" == "null" ]] || retry_args+=(--job-timeout "$retry_job_timeout")
276
+ # dispatch --vendor only accepts real CLI vendors; an exec gate is re-run
277
+ # through normal lane resolution plus the recorded --model script path.
278
+ [[ "$retry_vendor" == "exec" ]] || retry_args+=(--vendor "$retry_vendor")
279
+ [[ -z "$retry_model" ]] || retry_args+=(--model "$retry_model")
280
+ [[ -z "$retry_effort" ]] || retry_args+=(--effort "$retry_effort")
281
+ [[ "$retry_background" -eq 0 ]] || retry_args+=(--background)
282
+ exec bash "$OMNILANE_REPO/scripts/dispatch.sh" "${retry_args[@]}" \
283
+ "$retry_lane" - < "$task_path" ;;
284
+ audit)
285
+ limit=100
286
+ shift
287
+ while [[ $# -gt 0 ]]; do
288
+ case "$1" in
289
+ --last)
290
+ [[ $# -ge 2 ]] || usage
291
+ limit="$2"; shift 2 ;;
292
+ *) usage ;;
293
+ esac
294
+ done
295
+ [[ "$limit" =~ ^[1-9][0-9]{0,3}$ && "$limit" -le 10000 ]] || {
296
+ die 2 "invalid --last value (want 1..10000)"
297
+ }
298
+
299
+ findings=0
300
+ sampled=0
301
+ passed=0
302
+ failed=0
303
+ ids=()
304
+ audit_scopes=()
305
+ audit_codes=()
306
+ passed_ids=()
307
+ audit_emit() {
308
+ audit_scopes+=("$1")
309
+ audit_codes+=("$2")
310
+ [[ "$JSON_MODE" -eq 1 ]] || printf 'FAIL %s %s\n' "$1" "$2"
311
+ }
312
+ if [[ -d "$JOBS" ]]; then
313
+ mode="$(path_mode "$JOBS" 2>/dev/null || true)"
314
+ if [[ "$mode" != "700" ]]; then
315
+ audit_emit store unsafe-store-mode
316
+ findings=$((findings + 1))
317
+ fi
318
+ for job_dir in "$JOBS"/*; do
319
+ [[ -e "$job_dir" || -L "$job_dir" ]] || continue
320
+ if [[ -L "$job_dir" || ! -d "$job_dir" ]]; then
321
+ audit_emit store unsafe-job-entry
322
+ findings=$((findings + 1))
323
+ continue
324
+ fi
325
+ id="${job_dir##*/}"
326
+ if [[ ! "$id" =~ $JOB_ID_PATTERN ]]; then
327
+ audit_emit store invalid-job-name
328
+ findings=$((findings + 1))
329
+ continue
330
+ fi
331
+ ids+=("$id")
332
+ done
333
+ fi
334
+
335
+ if [[ ${#ids[@]} -gt 0 ]]; then
336
+ while IFS= read -r id; do
337
+ sampled=$((sampled + 1))
338
+ job_failed=0
339
+ job_dir="$JOBS/$id"
340
+ if [[ ! -d "$job_dir" || -L "$job_dir" ]]; then
341
+ audit_emit "$id" changed-job-path
342
+ findings=$((findings + 1)); failed=$((failed + 1))
343
+ continue
344
+ fi
345
+ mode="$(path_mode "$job_dir" 2>/dev/null || true)"
346
+ if [[ "$mode" != "700" ]]; then
347
+ audit_emit "$id" unsafe-job-mode
348
+ findings=$((findings + 1)); job_failed=1
349
+ fi
350
+ for artifact in "$job_dir"/*; do
351
+ [[ -e "$artifact" || -L "$artifact" ]] || continue
352
+ if [[ -L "$artifact" ]]; then
353
+ audit_emit "$id" symlink-artifact
354
+ findings=$((findings + 1)); job_failed=1
355
+ elif [[ -d "$artifact" ]]; then
356
+ audit_emit "$id" nested-directory
357
+ findings=$((findings + 1)); job_failed=1
358
+ elif [[ -f "$artifact" ]]; then
359
+ mode="$(path_mode "$artifact" 2>/dev/null || true)"
360
+ if [[ "$mode" != "600" ]]; then
361
+ audit_emit "$id" unsafe-file-mode
362
+ findings=$((findings + 1)); job_failed=1
363
+ fi
364
+ else
365
+ audit_emit "$id" unsafe-artifact-type
366
+ findings=$((findings + 1)); job_failed=1
367
+ fi
368
+ done
369
+ if [[ ! -f "$job_dir/task.txt" || -L "$job_dir/task.txt" ]]; then
370
+ audit_emit "$id" missing-task
371
+ findings=$((findings + 1)); job_failed=1
372
+ fi
373
+ if ! read_public_metadata "$job_dir/meta.json" ||
374
+ ! parse_audit_metadata "$PUBLIC_METADATA"; then
375
+ audit_emit "$id" invalid-metadata
376
+ findings=$((findings + 1)); job_failed=1
377
+ fi
378
+ if [[ -e "$job_dir/pid" || -L "$job_dir/pid" ]]; then
379
+ if ! read_job_pid "$job_dir/pid"; then
380
+ audit_emit "$id" invalid-pid
381
+ findings=$((findings + 1)); job_failed=1
382
+ fi
383
+ else
384
+ audit_emit "$id" missing-pid
385
+ findings=$((findings + 1)); job_failed=1
386
+ fi
387
+ if [[ -e "$job_dir/exit" || -L "$job_dir/exit" ]]; then
388
+ if ! read_exit_code "$job_dir/exit"; then
389
+ audit_emit "$id" invalid-exit
390
+ findings=$((findings + 1)); job_failed=1
391
+ fi
392
+ fi
393
+ if [[ "$job_failed" -eq 0 ]]; then
394
+ passed_ids+=("$id")
395
+ [[ "$JSON_MODE" -eq 1 ]] || printf 'PASS %s\n' "$id"
396
+ passed=$((passed + 1))
397
+ else
398
+ failed=$((failed + 1))
399
+ fi
400
+ done < <(printf '%s\n' "${ids[@]}" | LC_ALL=C sort -r | sed -n "1,${limit}p")
401
+ fi
402
+ if [[ "$JSON_MODE" -eq 1 ]]; then
403
+ printf '{"schema_version":1,"command":"audit","sampled":%s,"passed":%s,"failed":%s,"findings":[' \
404
+ "$sampled" "$passed" "$failed"
405
+ for ((index = 0; index < ${#audit_codes[@]}; index++)); do
406
+ [[ "$index" -eq 0 ]] || printf ','
407
+ printf '{"scope":"%s","code":"%s"}' \
408
+ "$(json_escape "${audit_scopes[$index]}")" "$(json_escape "${audit_codes[$index]}")"
409
+ done
410
+ printf '],"passed_ids":['
411
+ for ((index = 0; index < ${#passed_ids[@]}; index++)); do
412
+ [[ "$index" -eq 0 ]] || printf ','
413
+ printf '"%s"' "$(json_escape "${passed_ids[$index]}")"
414
+ done
415
+ printf ']}\n'
416
+ else
417
+ printf 'audit: sampled=%s passed=%s failed=%s findings=%s\n' \
418
+ "$sampled" "$passed" "$failed" "$findings"
419
+ fi
420
+ [[ "$findings" -eq 0 ]] || exit 1 ;;
421
+ stats)
422
+ limit=100
423
+ shift
424
+ while [[ $# -gt 0 ]]; do
425
+ case "$1" in
426
+ --last)
427
+ [[ $# -ge 2 ]] || usage
428
+ limit="$2"; shift 2 ;;
429
+ *) usage ;;
430
+ esac
431
+ done
432
+ [[ "$limit" =~ ^[1-9][0-9]{0,3}$ && "$limit" -le 10000 ]] || {
433
+ die 2 "invalid --last value (want 1..10000)"
434
+ }
435
+ sampled=0
436
+ succeeded=0
437
+ failed=0
438
+ running=0
439
+ invalid_exit=0
440
+ invalid_metadata=0
441
+ ids=()
442
+ lanes=()
443
+ vendors=()
444
+ if [[ -d "$JOBS" ]]; then
445
+ for job_dir in "$JOBS"/*; do
446
+ [[ -d "$job_dir" && ! -L "$job_dir" ]] || continue
447
+ id="${job_dir##*/}"
448
+ [[ "$id" =~ $JOB_ID_PATTERN ]] || continue
449
+ ids+=("$id")
450
+ done
451
+ fi
452
+ if [[ ${#ids[@]} -gt 0 ]]; then
453
+ while IFS= read -r id; do
454
+ sampled=$((sampled + 1))
455
+ job_dir="$JOBS/$id"
456
+ exit_path="$job_dir/exit"
457
+ if [[ -e "$exit_path" || -L "$exit_path" ]]; then
458
+ if read_exit_code "$exit_path"; then
459
+ if [[ "$RECORDED_EXIT" -eq 0 ]]; then
460
+ succeeded=$((succeeded + 1))
461
+ else
462
+ failed=$((failed + 1))
463
+ fi
464
+ else
465
+ invalid_exit=$((invalid_exit + 1))
466
+ fi
467
+ else
468
+ running=$((running + 1))
469
+ fi
470
+ metadata_path="$job_dir/meta.json"
471
+ if read_public_metadata "$metadata_path" &&
472
+ parse_stats_metadata "$PUBLIC_METADATA"; then
473
+ lanes+=("$STATS_LANE")
474
+ vendors+=("$STATS_VENDOR")
475
+ else
476
+ invalid_metadata=$((invalid_metadata + 1))
477
+ fi
478
+ done < <(printf '%s\n' "${ids[@]}" | LC_ALL=C sort -r | sed -n "1,${limit}p")
479
+ fi
480
+ completed=$((succeeded + failed))
481
+ success_rate=0
482
+ [[ "$completed" -eq 0 ]] || success_rate=$((succeeded * 100 / completed))
483
+ valid_metadata=$((sampled - invalid_metadata))
484
+ if [[ "$JSON_MODE" -eq 1 ]]; then
485
+ lanes_json='[]'
486
+ vendors_json='[]'
487
+ if [[ "$valid_metadata" -gt 0 ]]; then
488
+ lanes_json="$(json_stat_counts "${lanes[@]}")"
489
+ vendors_json="$(json_stat_counts "${vendors[@]}")"
490
+ fi
491
+ printf '{"schema_version":1,"command":"stats","ok":true,"sampled":%s,"succeeded":%s,"failed":%s,"running":%s,"invalid_exit":%s,"success_rate":%s,"invalid_metadata":%s,"lanes":%s,"vendors":%s}\n' \
492
+ "$sampled" "$succeeded" "$failed" "$running" "$invalid_exit" "$success_rate" \
493
+ "$invalid_metadata" "$lanes_json" "$vendors_json"
494
+ else
495
+ printf 'jobs: sampled=%s succeeded=%s failed=%s running=%s invalid_exit=%s success_rate=%s%%\n' \
496
+ "$sampled" "$succeeded" "$failed" "$running" "$invalid_exit" "$success_rate"
497
+ printf 'invalid_metadata=%s\n' "$invalid_metadata"
498
+ if [[ "$valid_metadata" -gt 0 ]]; then
499
+ print_stat_counts lane "${lanes[@]}"
500
+ print_stat_counts vendor "${vendors[@]}"
501
+ fi
502
+ fi ;;
503
+ list)
504
+ [[ $# -eq 1 ]] || usage
505
+ if [[ ! -d "$JOBS" ]]; then
506
+ [[ "$JSON_MODE" -eq 0 ]] || printf '{"schema_version":1,"command":"list","ok":true,"jobs":[]}\n'
507
+ exit 0
508
+ fi
509
+ ids=()
510
+ for job_dir in "$JOBS"/*; do
511
+ [[ -d "$job_dir" && ! -L "$job_dir" ]] || continue
512
+ id="${job_dir##*/}"
513
+ [[ "$id" =~ $JOB_ID_PATTERN ]] || continue
514
+ ids+=("$id")
515
+ done
516
+ if [[ ${#ids[@]} -eq 0 ]]; then
517
+ [[ "$JSON_MODE" -eq 0 ]] || printf '{"schema_version":1,"command":"list","ok":true,"jobs":[]}\n'
518
+ exit 0
519
+ fi
520
+ [[ "$JSON_MODE" -eq 0 ]] || printf '{"schema_version":1,"command":"list","ok":true,"jobs":['
521
+ json_first=1
522
+ while IFS= read -r id; do
523
+ state="running"
524
+ exit_json="null"
525
+ exit_path="$JOBS/$id/exit"
526
+ if [[ -e "$exit_path" || -L "$exit_path" ]]; then
527
+ if read_exit_code "$exit_path"; then
528
+ state="done(exit $RECORDED_EXIT)"
529
+ json_state="done"
530
+ exit_json="$RECORDED_EXIT"
531
+ else
532
+ state="done(invalid exit metadata)"
533
+ json_state="invalid"
534
+ fi
535
+ else
536
+ json_state="running"
537
+ fi
538
+ metadata=""
539
+ metadata_json="null"
540
+ metadata_status="missing"
541
+ metadata_path="$JOBS/$id/meta.json"
542
+ if [[ -f "$metadata_path" && ! -L "$metadata_path" ]]; then
543
+ if read_public_metadata "$metadata_path"; then
544
+ metadata="$PUBLIC_METADATA"
545
+ if [[ "$JSON_MODE" -eq 0 ]] || valid_utf8 "$PUBLIC_METADATA"; then
546
+ metadata_json="\"$(json_escape "$PUBLIC_METADATA")\""
547
+ metadata_status="valid"
548
+ else
549
+ metadata_status="invalid"
550
+ fi
551
+ else
552
+ metadata="[invalid metadata]"
553
+ metadata_status="invalid"
554
+ fi
555
+ elif [[ -e "$metadata_path" || -L "$metadata_path" ]]; then
556
+ metadata_status="invalid"
557
+ fi
558
+ if [[ "$JSON_MODE" -eq 1 ]]; then
559
+ [[ "$json_first" -eq 1 ]] || printf ','
560
+ json_first=0
561
+ printf '{"id":"%s","state":"%s","exit_code":%s,"metadata":%s,"metadata_status":"%s"}' \
562
+ "$(json_escape "$id")" "$json_state" "$exit_json" "$metadata_json" "$metadata_status"
563
+ else
564
+ printf '%s %s %s\n' "$id" "$state" "$metadata"
565
+ fi
566
+ done < <(printf '%s\n' "${ids[@]}" | LC_ALL=C sort -r | sed -n '1,20p')
567
+ [[ "$JSON_MODE" -eq 0 ]] || printf ']}\n' ;;
568
+ status)
569
+ [[ $# -eq 2 ]] || usage
570
+ select_job "$2"
571
+ exit_path="$JOB_DIR/exit"
572
+ if [[ -e "$exit_path" || -L "$exit_path" ]]; then
573
+ read_exit_code "$exit_path" || {
574
+ die 1 "invalid recorded exit status"
575
+ }
576
+ if [[ "$JSON_MODE" -eq 1 ]]; then
577
+ printf '{"schema_version":1,"command":"status","ok":true,"job":{"id":"%s","state":"done","exit_code":%s}}\n' \
578
+ "$(json_escape "$2")" "$RECORDED_EXIT"
579
+ else
580
+ echo "done exit=$RECORDED_EXIT"
581
+ fi
582
+ else
583
+ pid=""; pid_state="missing"
584
+ if [[ -e "$JOB_DIR/pid" || -L "$JOB_DIR/pid" ]]; then
585
+ if read_job_pid "$JOB_DIR/pid"; then
586
+ pid="$RECORDED_PID"; pid_state="valid"
587
+ else
588
+ pid_state="invalid"
589
+ fi
590
+ fi
591
+ if [[ "$pid_state" == "invalid" ]]; then
592
+ state="dead"; reason="invalid pid metadata"
593
+ elif [[ "$pid_state" == "valid" ]] && ! kill -0 "$pid" 2>/dev/null; then
594
+ state="dead"; reason="worker gone, no exit recorded"
595
+ else
596
+ state="running"; reason=""
597
+ fi
598
+ if [[ "$JSON_MODE" -eq 1 ]]; then
599
+ if [[ -n "$reason" ]]; then
600
+ printf '{"schema_version":1,"command":"status","ok":true,"job":{"id":"%s","state":"%s","exit_code":null,"reason":"%s"}}\n' \
601
+ "$(json_escape "$2")" "$state" "$(json_escape "$reason")"
602
+ else
603
+ printf '{"schema_version":1,"command":"status","ok":true,"job":{"id":"%s","state":"running","exit_code":null}}\n' \
604
+ "$(json_escape "$2")"
605
+ fi
606
+ elif [[ -n "$reason" ]]; then
607
+ printf 'dead (%s)\n' "$reason"
608
+ else
609
+ echo "running"
610
+ fi
611
+ fi ;;
612
+ wait)
613
+ [[ "$JSON_MODE" -eq 0 && $# -ge 2 ]] || usage
614
+ wait_id="$2"
615
+ wait_timeout=600
616
+ shift 2
617
+ while [[ $# -gt 0 ]]; do
618
+ case "$1" in
619
+ --timeout)
620
+ [[ $# -ge 2 ]] || usage
621
+ wait_timeout="$2"; shift 2 ;;
622
+ *) usage ;;
623
+ esac
624
+ done
625
+ [[ "$wait_timeout" =~ ^(0|[1-9][0-9]{0,4})$ && "$wait_timeout" -le 86400 ]] || {
626
+ die 2 "invalid --timeout value (want 0..86400)"
627
+ }
628
+ select_job "$wait_id"
629
+ wait_started="$SECONDS"
630
+ while true; do
631
+ [[ -d "$JOB_DIR" && ! -L "$JOB_DIR" ]] || {
632
+ die 1 "job disappeared while waiting"
633
+ }
634
+ exit_path="$JOB_DIR/exit"
635
+ if [[ -e "$exit_path" || -L "$exit_path" ]]; then
636
+ read_exit_code "$exit_path" || {
637
+ die 1 "invalid recorded exit status"
638
+ }
639
+ echo "done exit=$RECORDED_EXIT"
640
+ exit "$RECORDED_EXIT"
641
+ fi
642
+
643
+ pid=""; pid_state="missing"
644
+ if [[ -e "$JOB_DIR/pid" || -L "$JOB_DIR/pid" ]]; then
645
+ if read_job_pid "$JOB_DIR/pid"; then
646
+ pid="$RECORDED_PID"; pid_state="valid"
647
+ else
648
+ pid_state="invalid"
649
+ fi
650
+ fi
651
+ if [[ "$pid_state" == "invalid" ]]; then
652
+ # A pid file that vanished between the existence check and the bounded
653
+ # read is a job being removed, not corrupt metadata; loop again so the
654
+ # directory check reports the disappearance.
655
+ if [[ ! -e "$JOB_DIR/pid" && ! -L "$JOB_DIR/pid" ]]; then
656
+ continue
657
+ fi
658
+ echo "dead (invalid pid metadata)"
659
+ exit 125
660
+ elif [[ "$pid_state" == "valid" ]] && ! kill -0 "$pid" 2>/dev/null; then
661
+ if [[ -e "$exit_path" || -L "$exit_path" ]]; then
662
+ continue
663
+ fi
664
+ echo "dead (worker gone, no exit recorded)"
665
+ exit 125
666
+ fi
667
+
668
+ wait_elapsed=$((SECONDS - wait_started))
669
+ if [[ "$wait_elapsed" -ge "$wait_timeout" ]]; then
670
+ printf 'wait timeout after %ss\n' "$wait_timeout" >&2
671
+ exit 124
672
+ fi
673
+ sleep 1
674
+ done ;;
675
+ result)
676
+ [[ $# -eq 2 ]] || usage
677
+ select_job "$2"
678
+ exit_path="$JOB_DIR/exit"
679
+ [[ -e "$exit_path" || -L "$exit_path" ]] || {
680
+ die 1 "still running"
681
+ }
682
+ read_exit_code "$exit_path" || {
683
+ die 1 "invalid recorded exit status"
684
+ }
685
+ rc="$RECORDED_EXIT"
686
+ if [[ "$JSON_MODE" -eq 1 ]]; then
687
+ output_available=false
688
+ stderr_available=false
689
+ [[ -f "$JOB_DIR/out.txt" && ! -L "$JOB_DIR/out.txt" ]] && output_available=true
690
+ [[ -s "$JOB_DIR/out.txt.stderr.log" && ! -L "$JOB_DIR/out.txt.stderr.log" ]] && stderr_available=true
691
+ printf '{"schema_version":1,"command":"result","ok":true,"job":{"id":"%s","state":"done","exit_code":%s,"output_available":%s,"stderr_available":%s}}\n' \
692
+ "$(json_escape "$2")" "$rc" "$output_available" "$stderr_available"
693
+ else
694
+ # Guarded cat: under set -e a missing out.txt must not eat the real exit code.
695
+ [[ -f "$JOB_DIR/out.txt" && ! -L "$JOB_DIR/out.txt" ]] && cat "$JOB_DIR/out.txt"
696
+ if [[ -s "$JOB_DIR/out.txt.stderr.log" && ! -L "$JOB_DIR/out.txt.stderr.log" ]]; then
697
+ echo "--- stderr ---" >&2; cat "$JOB_DIR/out.txt.stderr.log" >&2
698
+ fi
699
+ fi
700
+ exit "$rc" ;;
701
+ prune)
702
+ keep=100
703
+ keep_given=0
704
+ older_than=""
705
+ apply=0
706
+ shift
707
+ while [[ $# -gt 0 ]]; do
708
+ case "$1" in
709
+ --keep)
710
+ [[ $# -ge 2 ]] || usage
711
+ keep="$2"; keep_given=1; shift 2 ;;
712
+ --older-than)
713
+ [[ $# -ge 2 ]] || usage
714
+ older_than="$2"; shift 2 ;;
715
+ --apply)
716
+ apply=1; shift ;;
717
+ *) usage ;;
718
+ esac
719
+ done
720
+ [[ "$keep" =~ ^(0|[1-9][0-9]{0,8})$ ]] || {
721
+ echo "invalid --keep value (want 0..999999999)" >&2
722
+ exit 2
723
+ }
724
+ cutoff=""
725
+ if [[ -n "$older_than" ]]; then
726
+ [[ "$older_than" =~ ^[1-9][0-9]{0,3}$ ]] || {
727
+ die 2 "invalid --older-than value (want 1..9999 days)"
728
+ }
729
+ # Age comes from the timestamp embedded in the job id (local time, the
730
+ # same clock that created it), not from mtime, which any tool can touch.
731
+ # The fixed-width YYYYMMDD-HHMMSS format makes string comparison exact.
732
+ if ! cutoff="$(date -v "-${older_than}d" +%Y%m%d-%H%M%S 2>/dev/null)" &&
733
+ ! cutoff="$(date -d "${older_than} days ago" +%Y%m%d-%H%M%S 2>/dev/null)"; then
734
+ die 1 "cannot compute --older-than cutoff on this host's date command"
735
+ fi
736
+ [[ "$cutoff" =~ ^[0-9]{8}-[0-9]{6}$ ]] || {
737
+ die 1 "cannot compute --older-than cutoff on this host's date command"
738
+ }
739
+ # --older-than alone prunes purely by age; an explicit --keep composes
740
+ # as AND (a job must be both beyond the keep window and old enough).
741
+ [[ "$keep_given" -eq 1 ]] || keep=0
742
+ fi
743
+ completed=()
744
+ if [[ -d "$JOBS" ]]; then
745
+ for job_dir in "$JOBS"/*; do
746
+ [[ -d "$job_dir" && ! -L "$job_dir" ]] || continue
747
+ id="${job_dir##*/}"
748
+ [[ "$id" =~ $JOB_ID_PATTERN ]] || continue
749
+ read_exit_code "$job_dir/exit" || continue
750
+ completed+=("$id")
751
+ done
752
+ fi
753
+ candidates=()
754
+ index=0
755
+ if [[ ${#completed[@]} -gt 0 ]]; then
756
+ while IFS= read -r id; do
757
+ index=$((index + 1))
758
+ [[ "$index" -le "$keep" ]] && continue
759
+ if [[ -n "$cutoff" ]]; then
760
+ [[ "${id:0:15}" < "$cutoff" ]] || continue
761
+ fi
762
+ candidates+=("$id")
763
+ done < <(printf '%s\n' "${completed[@]}" | sort -r)
764
+ fi
765
+
766
+ deleted=0
767
+ # ${arr[@]} on an empty array is an unbound-variable error under set -u
768
+ # on Bash 3.2, so an empty candidate list must never reach the loop.
769
+ if [[ ${#candidates[@]} -gt 0 ]]; then
770
+ for id in "${candidates[@]}"; do
771
+ job_dir="$JOBS/$id"
772
+ if [[ "$apply" -eq 0 ]]; then
773
+ printf 'would delete %s\n' "$id"
774
+ continue
775
+ fi
776
+ # Re-check immediately before deletion so a replaced path or a job whose
777
+ # completion marker disappeared is never removed.
778
+ if [[ -d "$job_dir" && ! -L "$job_dir" ]] &&
779
+ read_exit_code "$job_dir/exit"; then
780
+ /bin/rm -rf "$job_dir"
781
+ printf 'deleted %s\n' "$id"
782
+ deleted=$((deleted + 1))
783
+ else
784
+ printf 'skipped changed job %s\n' "$id" >&2
785
+ fi
786
+ done
787
+ fi
788
+ if [[ "$apply" -eq 1 ]]; then
789
+ if [[ -n "$older_than" && "$keep_given" -eq 1 ]]; then
790
+ printf '%s jobs deleted; kept the newest %s completed jobs and everything newer than %s days\n' \
791
+ "$deleted" "$keep" "$older_than"
792
+ elif [[ -n "$older_than" ]]; then
793
+ printf '%s jobs deleted; completed jobs newer than %s days retained\n' \
794
+ "$deleted" "$older_than"
795
+ else
796
+ printf '%s jobs deleted; newest %s completed jobs retained\n' "$deleted" "$keep"
797
+ fi
798
+ else
799
+ printf '%s jobs eligible; rerun with --apply to delete\n' "${#candidates[@]}"
800
+ fi ;;
801
+ *) usage ;;
802
+ esac