omnilane 0.13.0 → 0.15.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.
package/scripts/jobs.sh CHANGED
@@ -2,6 +2,7 @@
2
2
  set -euo pipefail
3
3
  # omnilane background-job helper.
4
4
  # Usage: jobs.sh [--json] list | status JOB_ID | result JOB_ID | stats [--last N]
5
+ # jobs.sh [--json] recommend [--last N] [--lane L] [--min-samples N]
5
6
  # jobs.sh wait JOB_ID [--timeout N]
6
7
  # jobs.sh audit [--last N] [--json] | prune [--keep N] [--apply]
7
8
 
@@ -48,7 +49,7 @@ die() {
48
49
  exit "$rc"
49
50
  }
50
51
 
51
- USAGE_TEXT="usage: jobs.sh [--json] list [--lane L] [--vendor V] [--status running|done]|status ID|result ID|tail ID [--lines N]|retry ID [--background]|stats [--last N] [--lane L] [--vendor V]|wait ID [--timeout N]|cancel ID|rm ID|audit [--last N]|prune [--keep N] [--older-than DAYS] [--apply]|help"
52
+ USAGE_TEXT="usage: jobs.sh [--json] list [--lane L] [--vendor V] [--status running|done]|status ID|result ID|tail ID [--lines N]|retry ID [--background]|stats [--last N] [--lane L] [--vendor V]|recommend [--last N] [--lane L] [--min-samples N]|wait ID [--timeout N]|cancel ID|rm ID|audit [--last N]|prune [--keep N] [--older-than DAYS] [--apply]|help"
52
53
 
53
54
  usage() {
54
55
  die 2 "$USAGE_TEXT"
@@ -168,6 +169,83 @@ json_stat_counts() {
168
169
  printf ']'
169
170
  }
170
171
 
172
+ build_recommendation_rows() {
173
+ local minimum="$1"
174
+ shift
175
+ [[ $# -gt 0 ]] || return 0
176
+ printf '%s\n' "$@" | awk -F '\t' -v minimum="$minimum" '
177
+ {
178
+ key = $1 SUBSEP $2
179
+ samples[key]++
180
+ if ($3 == 0) succeeded[key]++
181
+ else failed[key]++
182
+ }
183
+ END {
184
+ for (key in samples) {
185
+ split(key, parts, SUBSEP)
186
+ rate = int((succeeded[key] * 100) / samples[key])
187
+ eligible = samples[key] >= minimum ? 1 : 0
188
+ printf "%s\t%d\t%d\t%d\t%s\t%d\t%d\n", \
189
+ parts[1], eligible, rate, samples[key], parts[2], \
190
+ succeeded[key] + 0, failed[key] + 0
191
+ }
192
+ }
193
+ ' | LC_ALL=C sort -t $'\t' -k1,1 -k2,2nr -k3,3nr -k4,4nr -k5,5
194
+ }
195
+
196
+ recommendations_json() {
197
+ local rows="$1"
198
+ [[ -n "$rows" ]] || { printf '[]'; return 0; }
199
+ printf '%s\n' "$rows" | awk -F '\t' '
200
+ function close_lane( status, recommendation) {
201
+ if (current == "") return
202
+ status = recommended == "" ? "insufficient_data" : "ready"
203
+ recommendation = recommended == "" ? "null" : "\"" recommended "\""
204
+ printf "],\"status\":\"%s\",\"recommended_vendor\":%s}", status, recommendation
205
+ }
206
+ BEGIN { printf "["; current = ""; first_lane = 1 }
207
+ {
208
+ if ($1 != current) {
209
+ close_lane()
210
+ if (!first_lane) printf ","
211
+ first_lane = 0
212
+ current = $1
213
+ first_candidate = 1
214
+ recommended = ""
215
+ printf "{\"lane\":\"%s\",\"candidates\":[", current
216
+ }
217
+ if (!first_candidate) printf ","
218
+ first_candidate = 0
219
+ eligible = $2 == 1 ? "true" : "false"
220
+ printf "{\"vendor\":\"%s\",\"samples\":%d,\"succeeded\":%d,\"failed\":%d,\"success_rate\":%d,\"eligible\":%s}", \
221
+ $5, $4, $6, $7, $3, eligible
222
+ if (recommended == "" && $2 == 1) recommended = $5
223
+ }
224
+ END { close_lane(); printf "]" }
225
+ '
226
+ }
227
+
228
+ print_recommendations() {
229
+ local rows="$1" minimum="$2"
230
+ [[ -n "$rows" ]] || return 0
231
+ printf '%s\n' "$rows" | awk -F '\t' -v minimum="$minimum" '
232
+ $1 != current {
233
+ current = $1
234
+ if ($2 == 1) {
235
+ printf "recommend lane=%s vendor=%s success_rate=%d%% samples=%d succeeded=%d failed=%d\n", \
236
+ $1, $5, $3, $4, $6, $7
237
+ } else {
238
+ printf "recommend lane=%s status=insufficient_data min_samples=%d\n", $1, minimum
239
+ }
240
+ }
241
+ {
242
+ eligible = $2 == 1 ? "yes" : "no"
243
+ printf "candidate lane=%s vendor=%s success_rate=%d%% samples=%d succeeded=%d failed=%d eligible=%s\n", \
244
+ $1, $5, $3, $4, $6, $7, eligible
245
+ }
246
+ '
247
+ }
248
+
171
249
  path_mode() {
172
250
  if stat -f '%Lp' "$1" >/dev/null 2>&1; then
173
251
  stat -f '%Lp' "$1"
@@ -191,7 +269,7 @@ set -- ${args[@]+"${args[@]}"}
191
269
  COMMAND="${1:-unknown}"
192
270
  if [[ "$JSON_MODE" -eq 1 ]]; then
193
271
  case "$COMMAND" in
194
- list|status|result|stats|audit) ;;
272
+ list|status|result|stats|recommend|audit) ;;
195
273
  *) usage ;;
196
274
  esac
197
275
  fi
@@ -418,6 +496,88 @@ case "${1:-}" in
418
496
  "$sampled" "$passed" "$failed" "$findings"
419
497
  fi
420
498
  [[ "$findings" -eq 0 ]] || exit 1 ;;
499
+ recommend)
500
+ limit=100
501
+ minimum_samples=3
502
+ filter_lane=""
503
+ shift
504
+ while [[ $# -gt 0 ]]; do
505
+ case "$1" in
506
+ --last)
507
+ [[ $# -ge 2 ]] || usage
508
+ limit="$2"; shift 2 ;;
509
+ --lane)
510
+ [[ $# -ge 2 ]] || usage
511
+ filter_lane="$2"; shift 2 ;;
512
+ --min-samples)
513
+ [[ $# -ge 2 ]] || usage
514
+ minimum_samples="$2"; shift 2 ;;
515
+ *) usage ;;
516
+ esac
517
+ done
518
+ [[ "$limit" =~ ^[1-9][0-9]{0,3}$ && "$limit" -le 10000 ]] || {
519
+ die 2 "invalid --last value (want 1..10000)"
520
+ }
521
+ [[ "$minimum_samples" =~ ^[1-9][0-9]{0,2}$ && "$minimum_samples" -le 1000 ]] || {
522
+ die 2 "invalid --min-samples value (want 1..1000)"
523
+ }
524
+ [[ -z "$filter_lane" || "$filter_lane" =~ ^[a-z][a-z0-9-]*$ ]] || die 2 "invalid --lane value"
525
+ sampled=0
526
+ completed=0
527
+ running=0
528
+ invalid_exit=0
529
+ invalid_metadata=0
530
+ ids=()
531
+ outcomes=()
532
+ if [[ -d "$JOBS" ]]; then
533
+ for job_dir in "$JOBS"/*; do
534
+ [[ -d "$job_dir" && ! -L "$job_dir" ]] || continue
535
+ id="${job_dir##*/}"
536
+ [[ "$id" =~ $JOB_ID_PATTERN ]] || continue
537
+ ids+=("$id")
538
+ done
539
+ fi
540
+ if [[ ${#ids[@]} -gt 0 ]]; then
541
+ while IFS= read -r id; do
542
+ job_dir="$JOBS/$id"
543
+ metadata_path="$job_dir/meta.json"
544
+ meta_ok=0
545
+ if read_public_metadata "$metadata_path" &&
546
+ parse_stats_metadata "$PUBLIC_METADATA"; then
547
+ meta_ok=1
548
+ fi
549
+ if [[ -n "$filter_lane" ]]; then
550
+ [[ "$meta_ok" -eq 1 && "$filter_lane" == "$STATS_LANE" ]] || continue
551
+ fi
552
+ sampled=$((sampled + 1))
553
+ if [[ "$meta_ok" -ne 1 ]]; then
554
+ invalid_metadata=$((invalid_metadata + 1))
555
+ continue
556
+ fi
557
+ exit_path="$job_dir/exit"
558
+ if [[ ! -e "$exit_path" && ! -L "$exit_path" ]]; then
559
+ running=$((running + 1))
560
+ continue
561
+ fi
562
+ if ! read_exit_code "$exit_path"; then
563
+ invalid_exit=$((invalid_exit + 1))
564
+ continue
565
+ fi
566
+ completed=$((completed + 1))
567
+ outcomes+=("$STATS_LANE"$'\t'"$STATS_VENDOR"$'\t'"$RECORDED_EXIT")
568
+ done < <(printf '%s\n' "${ids[@]}" | LC_ALL=C sort -r | sed -n "1,${limit}p")
569
+ fi
570
+ rows="$(build_recommendation_rows "$minimum_samples" ${outcomes[@]+"${outcomes[@]}"})"
571
+ if [[ "$JSON_MODE" -eq 1 ]]; then
572
+ recommendations="$(recommendations_json "$rows")"
573
+ printf '{"schema_version":1,"command":"recommend","ok":true,"sampled":%s,"completed":%s,"excluded":{"running":%s,"invalid_exit":%s,"invalid_metadata":%s},"minimum_samples":%s,"recommendations":%s}\n' \
574
+ "$sampled" "$completed" "$running" "$invalid_exit" "$invalid_metadata" \
575
+ "$minimum_samples" "$recommendations"
576
+ else
577
+ printf 'recommendations: sampled=%s completed=%s running=%s invalid_exit=%s invalid_metadata=%s min_samples=%s\n' \
578
+ "$sampled" "$completed" "$running" "$invalid_exit" "$invalid_metadata" "$minimum_samples"
579
+ print_recommendations "$rows" "$minimum_samples"
580
+ fi ;;
421
581
  stats)
422
582
  limit=100
423
583
  filter_lane=""; filter_vendor=""
@@ -181,6 +181,25 @@ read_lock_owner() {
181
181
  LOCK_OWNER_VALUE="$value"
182
182
  }
183
183
 
184
+ prepare_private_store() { # path, diagnostic label
185
+ local store_root="$1" store_label="$2"
186
+ mkdir -p "$OMNILANE_HOME" || return 1
187
+ if [[ -L "$store_root" || ( -e "$store_root" && ! -d "$store_root" ) ]]; then
188
+ echo "omnilane: unsafe $store_label path (want real directory): $store_root" >&2
189
+ return 1
190
+ fi
191
+ [[ -d "$store_root" ]] || mkdir -m 700 "$store_root" || return 1
192
+ [[ -d "$store_root" && ! -L "$store_root" ]] || {
193
+ echo "omnilane: $store_label changed while preparing it" >&2
194
+ return 1
195
+ }
196
+ chmod 700 "$store_root" || return 1
197
+ }
198
+
199
+ prepare_inbox_store() {
200
+ prepare_private_store "$OMNILANE_HOME/inbox" "inbox store"
201
+ }
202
+
184
203
  prepare_lock_store() {
185
204
  local lock_root="$OMNILANE_HOME/locks"
186
205
  mkdir -p "$OMNILANE_HOME"
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Explicit, bounded live-inference probe for exactly one configured provider.
5
+ # No provider is contacted unless --vendor is present. Provider output and
6
+ # stderr stay inside a private temporary directory and are never relayed.
7
+
8
+ SELF_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+ REPO="${OMNILANE_PROBE_REPO:-$SELF_REPO}"
10
+ DISPATCH="$REPO/scripts/dispatch.sh"
11
+ JSON_MODE=0
12
+ VENDOR=""
13
+ TIMEOUT=30
14
+
15
+ usage() {
16
+ echo "usage: provider-probe.sh --vendor V [--timeout SEC] [--json]" >&2
17
+ exit 2
18
+ }
19
+
20
+ json_escape() {
21
+ local s="$1" out="" ch escaped code i
22
+ for ((i = 0; i < ${#s}; i++)); do
23
+ ch="${s:i:1}"
24
+ case "$ch" in
25
+ '"') out="$out\\\"" ;;
26
+ '\\') out="$out\\\\" ;;
27
+ $'\b') out="$out\\b" ;;
28
+ $'\f') out="$out\\f" ;;
29
+ $'\n') out="$out\\n" ;;
30
+ $'\r') out="$out\\r" ;;
31
+ $'\t') out="$out\\t" ;;
32
+ *)
33
+ LC_CTYPE=C printf -v code '%d' "'$ch"
34
+ if [[ "$code" -ge 0 && "$code" -lt 32 ]]; then
35
+ printf -v escaped '\\u%04x' "$code"
36
+ out="$out$escaped"
37
+ else
38
+ out="$out$ch"
39
+ fi
40
+ ;;
41
+ esac
42
+ done
43
+ printf '%s' "$out"
44
+ }
45
+
46
+ emit_result() {
47
+ local ok="$1" status="$2" invoked="$3" reason="$4"
48
+ local duration="$5" response_bytes="$6"
49
+ if [[ "$JSON_MODE" -eq 1 ]]; then
50
+ printf '{"schema_version":1,"command":"provider-probe","ok":%s,"vendor":"%s","model":"%s","status":"%s","provider_invoked":%s,"timeout":%s,"duration_seconds":%s,"response_bytes":%s,"reason":"%s"}\n' \
51
+ "$ok" "$(json_escape "$VENDOR")" "$(json_escape "${MODEL:-}")" \
52
+ "$(json_escape "$status")" "$invoked" "$TIMEOUT" "$duration" \
53
+ "$response_bytes" "$(json_escape "$reason")"
54
+ else
55
+ printf 'provider_probe vendor=%s model=%s status=%s provider_invoked=%s timeout=%ss duration=%ss response_bytes=%s reason=%s\n' \
56
+ "$VENDOR" "${MODEL:-unknown}" "$status" "$invoked" "$TIMEOUT" \
57
+ "$duration" "$response_bytes" "$reason"
58
+ fi
59
+ }
60
+
61
+ while [[ $# -gt 0 ]]; do
62
+ case "$1" in
63
+ --vendor)
64
+ [[ $# -ge 2 ]] || usage
65
+ VENDOR="$2"; shift 2 ;;
66
+ --timeout)
67
+ [[ $# -ge 2 ]] || usage
68
+ TIMEOUT="$2"; shift 2 ;;
69
+ --json)
70
+ JSON_MODE=1; shift ;;
71
+ *) usage ;;
72
+ esac
73
+ done
74
+
75
+ [[ "$VENDOR" =~ ^(codex|claude|grok|gemini|kimi|qwen|opencode|openrouter|deepseek|zai|mistral|groq|cerebras)$ ]] || {
76
+ echo "omnilane: invalid vendor for provider probe" >&2
77
+ exit 2
78
+ }
79
+ [[ "$TIMEOUT" =~ ^[1-9][0-9]{0,2}$ && "$TIMEOUT" -le 300 ]] || {
80
+ echo "omnilane: invalid probe timeout (want 1..300 seconds)" >&2
81
+ exit 2
82
+ }
83
+ [[ -x "$DISPATCH" ]] || {
84
+ emit_result false unavailable false "dispatch is unavailable" 0 0
85
+ exit 1
86
+ }
87
+
88
+ PLAN=""
89
+ while IFS= read -r lane; do
90
+ [[ "$lane" =~ ^[a-z][a-z0-9-]*$ ]] || continue
91
+ set +e
92
+ candidate="$(bash "$DISPATCH" --dry-run --mode advise --vendor "$VENDOR" \
93
+ --timeout "$TIMEOUT" "$lane" "OMNILANE_PROVIDER_PROBE" 2>/dev/null)"
94
+ candidate_rc=$?
95
+ set -e
96
+ if [[ "$candidate_rc" -eq 0 && "$candidate" == *"provider_invoked=no"* ]]; then
97
+ PLAN="$candidate"
98
+ break
99
+ fi
100
+ done < <(bash "$DISPATCH" --list 2>/dev/null | sed -n 's/^\([a-z][a-z0-9-]*\):.*/\1/p')
101
+
102
+ if [[ -z "$PLAN" ]]; then
103
+ emit_result false unavailable false "vendor is not both configured and available" 0 0
104
+ exit 4
105
+ fi
106
+
107
+ field_value() {
108
+ local key="$1"
109
+ printf '%s\n' "$PLAN" | sed -n "s/^${key}=//p" | sed -n '1p'
110
+ }
111
+
112
+ decode_plan_value() {
113
+ local encoded="$1" decoded
114
+ [[ ${#encoded} -le 4096 && "$encoded" != *$'\n'* ]] || return 1
115
+ # dispatch.sh emits values with Bash printf %q. read without -r reverses
116
+ # its backslash quoting without evaluating command substitutions or code.
117
+ IFS= read decoded <<< "$encoded" || return 1
118
+ printf '%s' "$decoded"
119
+ }
120
+
121
+ RAW_MODEL="$(field_value model)"
122
+ RAW_EFFORT="$(field_value effort)"
123
+ if ! MODEL="$(decode_plan_value "$RAW_MODEL")" ||
124
+ ! EFFORT="$(decode_plan_value "$RAW_EFFORT")"; then
125
+ emit_result false unavailable false "invalid dry-run field encoding" 0 0
126
+ exit 4
127
+ fi
128
+ RUNNER="$REPO/scripts/runners/run-$VENDOR.sh"
129
+ [[ -n "$MODEL" && -x "$RUNNER" ]] || {
130
+ emit_result false unavailable false "resolved runner or model is unavailable" 0 0
131
+ exit 4
132
+ }
133
+
134
+ TMP_BASE="${TMPDIR:-/tmp}"
135
+ TMP_ROOT="$(mktemp -d "$TMP_BASE/omnilane-provider-probe.XXXXXX")"
136
+ cleanup() {
137
+ local path
138
+ case "${TMP_ROOT:-}" in
139
+ "$TMP_BASE"/omnilane-provider-probe.*)
140
+ if [[ -d "$TMP_ROOT" && ! -L "$TMP_ROOT" ]]; then
141
+ for path in "$TMP_ROOT"/* "$TMP_ROOT"/.[!.]*; do
142
+ [[ -f "$path" || -L "$path" ]] && rm -- "$path"
143
+ done
144
+ rmdir "$TMP_ROOT" 2>/dev/null || true
145
+ fi
146
+ ;;
147
+ esac
148
+ }
149
+ trap cleanup EXIT HUP INT TERM
150
+ PROMPT_FILE="$TMP_ROOT/prompt.txt"
151
+ OUTPUT_FILE="$TMP_ROOT/output.txt"
152
+ (umask 077; printf '%s\n' \
153
+ 'Reply with exactly OMNILANE_PROVIDER_PROBE_OK. Do not use tools or inspect files.' \
154
+ > "$PROMPT_FILE")
155
+
156
+ started="$(date +%s)"
157
+ set +e
158
+ OMNILANE_TIMEOUT="$TIMEOUT" "$RUNNER" advise \
159
+ "${OMNILANE_PROBE_WORKDIR:-$PWD}" "$MODEL" "$EFFORT" \
160
+ "$PROMPT_FILE" "$OUTPUT_FILE" >/dev/null 2>&1
161
+ runner_rc=$?
162
+ set -e
163
+ ended="$(date +%s)"
164
+ duration=$((ended - started))
165
+ response_bytes=0
166
+ if [[ -f "$OUTPUT_FILE" && ! -L "$OUTPUT_FILE" ]]; then
167
+ response_bytes="$(wc -c < "$OUTPUT_FILE" | tr -d '[:space:]')"
168
+ [[ "$response_bytes" =~ ^[0-9]+$ ]] || response_bytes=0
169
+ fi
170
+
171
+ if [[ "$runner_rc" -eq 0 && "$response_bytes" -gt 0 ]]; then
172
+ emit_result true usable true "bounded live inference returned output" "$duration" "$response_bytes"
173
+ exit 0
174
+ fi
175
+ if [[ "$runner_rc" -eq 124 || "$runner_rc" -eq 142 ]]; then
176
+ emit_result false timeout true "bounded live inference timed out" "$duration" "$response_bytes"
177
+ else
178
+ emit_result false failed true "runner exited with status $runner_rc" "$duration" "$response_bytes"
179
+ fi
180
+ exit 1
@@ -16,7 +16,9 @@ RUN_TIMEOUT="${OMNILANE_TIMEOUT:-600}"
16
16
 
17
17
  # --skip-git-repo-check: the operator chose WORKDIR explicitly; codex would
18
18
  # otherwise refuse any directory that is not a trusted git repo.
19
- ARGS=(exec -m "$MODEL" -o "${OUTPUT_FILE}.tmp" --skip-git-repo-check)
19
+ # --json: without it codex writes nothing until it exits, so a watchdog kill
20
+ # leaves an empty progress log that looks identical to a run that never started.
21
+ ARGS=(exec --json -m "$MODEL" -o "${OUTPUT_FILE}.tmp" --skip-git-repo-check)
20
22
  [[ -n "$EFFORT" && "$EFFORT" != "-" ]] && ARGS+=(-c "model_reasoning_effort=\"$EFFORT\"")
21
23
  if [[ "$MODE" == "advise" ]]; then
22
24
  ARGS+=(--ephemeral -s read-only)
@@ -41,10 +43,25 @@ set +e
41
43
  RC=$?
42
44
  set -e
43
45
 
44
- # Watchdog kill with an empty progress log = codex produced nothing at all;
45
- # the usual cause is a quota-window 429 retry loop, which codex retries silently.
46
- if [[ ("$RC" -eq 142 || "$RC" -eq 124) && ! -s "${OUTPUT_FILE}.progress.log" ]]; then
47
- echo "omnilane: codex timed out after ${RUN_TIMEOUT}s with no output — likely a usage-limit retry loop; check your codex quota window and retry" >> "${OUTPUT_FILE}.stderr.log"
46
+ if [[ "$RC" -eq 142 || "$RC" -eq 124 ]]; then
47
+ {
48
+ echo "omnilane: codex timed out after ${RUN_TIMEOUT}s. This does NOT identify the cause."
49
+ echo "omnilane: an empty progress log is NOT evidence of a stall check, in order:"
50
+ echo " 1. the streamed events in ${OUTPUT_FILE}.progress.log — the last event says how far the run got"
51
+ echo " 2. the rollout file below — if it stops at task_started, the request never left the CLI"
52
+ echo " 3. your provider proxy's log for the run window: rate-limit responses, and whether a"
53
+ echo " request was ever sent upstream at all"
54
+ # The rollout holds the full turn history a killed run never got to report;
55
+ # thread_id is the only handle onto it once the process is gone.
56
+ thread_id="$(head -1 "${OUTPUT_FILE}.progress.log" 2>/dev/null |
57
+ sed -n 's/.*"thread_id":"\([^"]*\)".*/\1/p')"
58
+ if [[ -n "$thread_id" ]]; then
59
+ rollout="$(find "${CODEX_HOME:-$HOME/.codex}/sessions" -name "rollout-*-${thread_id}.jsonl" 2>/dev/null | head -1)"
60
+ echo "omnilane: rollout: ${rollout:-not found (thread ${thread_id})}"
61
+ else
62
+ echo "omnilane: rollout: unknown — no thread_id in the progress log, so codex died before its first event"
63
+ fi
64
+ } >> "${OUTPUT_FILE}.stderr.log"
48
65
  fi
49
66
 
50
67
  if [[ -f "${OUTPUT_FILE}.tmp" ]]; then
package/ui/app.js CHANGED
@@ -21,6 +21,7 @@
21
21
  connectionLabel: document.getElementById("connection-label"),
22
22
  languageSelect: document.getElementById("language-select"),
23
23
  jobCount: document.getElementById("job-count"), search: document.getElementById("job-search"),
24
+ exportVisible: document.getElementById("export-visible"),
24
25
  filter: document.getElementById("status-filter"),
25
26
  filterButtons: Array.from(document.querySelectorAll(".filter-button")),
26
27
  jobList: document.getElementById("job-list"), listMessage: document.getElementById("list-message"),
@@ -296,6 +297,7 @@
296
297
 
297
298
  function setControlsDisabled(disabled) {
298
299
  elements.search.disabled = disabled;
300
+ elements.exportVisible.disabled = disabled;
299
301
  elements.filterButtons.forEach(function (button) {
300
302
  button.disabled = disabled;
301
303
  });
@@ -471,6 +473,52 @@
471
473
  });
472
474
  }
473
475
 
476
+ function exportField(meta, key) {
477
+ return typeof meta[key] === "string" && meta[key].length > 0 ? meta[key] : null;
478
+ }
479
+
480
+ function exportVisibleJobs() {
481
+ const jobs = visibleJobs();
482
+ if (jobs.length === 0) {
483
+ return;
484
+ }
485
+ const exportedAt = new Date();
486
+ const payload = {
487
+ schema_version: 1,
488
+ exported_at: exportedAt.toISOString(),
489
+ query: state.query,
490
+ filter: state.filter,
491
+ jobs: jobs.map(function (job) {
492
+ const meta = job.meta;
493
+ return {
494
+ id: job.id,
495
+ state: job.state,
496
+ exit_code: job.exitCode,
497
+ lane: exportField(meta, "lane"),
498
+ vendor: exportField(meta, "vendor"),
499
+ model: exportField(meta, "model"),
500
+ effort: exportField(meta, "effort"),
501
+ mode: exportField(meta, "mode"),
502
+ candidate: exportField(meta, "candidate"),
503
+ started: exportField(meta, "started"),
504
+ timeout: Number.isInteger(meta.timeout) ? meta.timeout : null,
505
+ };
506
+ }),
507
+ };
508
+ const blob = new Blob([JSON.stringify(payload, null, 2) + "\n"], {
509
+ type: "application/json;charset=utf-8",
510
+ });
511
+ const url = URL.createObjectURL(blob);
512
+ const stamp = exportedAt.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
513
+ const anchor = document.createElement("a");
514
+ anchor.href = url;
515
+ anchor.download = "omnilane-jobs-" + stamp + ".json";
516
+ document.body.appendChild(anchor);
517
+ anchor.click();
518
+ anchor.remove();
519
+ window.setTimeout(function () { URL.revokeObjectURL(url); }, 0);
520
+ }
521
+
474
522
  function routeLabel(job) {
475
523
  const meta = job.meta;
476
524
  return [
@@ -525,6 +573,7 @@
525
573
 
526
574
  function renderQueue() {
527
575
  const jobs = visibleJobs();
576
+ elements.exportVisible.disabled = state.unauthorized || jobs.length === 0;
528
577
  const listScroll = elements.jobList.scrollTop;
529
578
  const inspectorScroll = elements.inspector.scrollTop;
530
579
  const focusedCard = document.activeElement && document.activeElement.closest
@@ -1270,6 +1319,8 @@
1270
1319
  updateFilterSelection();
1271
1320
  });
1272
1321
 
1322
+ elements.exportVisible.addEventListener("click", exportVisibleJobs);
1323
+
1273
1324
  elements.mobileBack.addEventListener("click", function () {
1274
1325
  returnToMobileList(true);
1275
1326
  });
@@ -1327,6 +1378,7 @@
1327
1378
  "filter.active": "Active",
1328
1379
  "filter.done": "Done",
1329
1380
  "filter.issues": "Issues",
1381
+ "export.visible": "Export visible",
1330
1382
  "list.ariaLabel": "Recent tasks",
1331
1383
  "list.waiting": "Waiting for tasks.",
1332
1384
  "list.empty": "No tasks yet.",
@@ -1438,6 +1490,7 @@
1438
1490
  "filter.active": "実行中",
1439
1491
  "filter.done": "完了",
1440
1492
  "filter.issues": "問題あり",
1493
+ "export.visible": "表示中をエクスポート",
1441
1494
  "list.ariaLabel": "最近のタスク",
1442
1495
  "list.waiting": "タスクを待機しています。",
1443
1496
  "list.empty": "タスクはまだありません。",
@@ -1549,6 +1602,7 @@
1549
1602
  "filter.active": "진행 중",
1550
1603
  "filter.done": "완료",
1551
1604
  "filter.issues": "문제",
1605
+ "export.visible": "표시 항목 내보내기",
1552
1606
  "list.ariaLabel": "최근 작업",
1553
1607
  "list.waiting": "작업을 기다리는 중입니다.",
1554
1608
  "list.empty": "아직 작업이 없습니다.",
@@ -1660,6 +1714,7 @@
1660
1714
  "filter.active": "進行中",
1661
1715
  "filter.done": "完成",
1662
1716
  "filter.issues": "有問題",
1717
+ "export.visible": "匯出目前結果",
1663
1718
  "list.ariaLabel": "最近的任務",
1664
1719
  "list.waiting": "等待任務中。",
1665
1720
  "list.empty": "尚無任務。",
@@ -1771,6 +1826,7 @@
1771
1826
  "filter.active": "进行中",
1772
1827
  "filter.done": "完成",
1773
1828
  "filter.issues": "有问题",
1829
+ "export.visible": "导出当前结果",
1774
1830
  "list.ariaLabel": "最近的任务",
1775
1831
  "list.waiting": "等待任务中。",
1776
1832
  "list.empty": "暂无任务。",
package/ui/index.html CHANGED
@@ -39,6 +39,7 @@
39
39
  <p class="section-label" data-i18n="index.sectionLabel">Recent work</p>
40
40
  <h1 id="dispatch-index-title" data-i18n="index.title">Task record</h1>
41
41
  </div>
42
+ <button type="button" class="export-button" id="export-visible" data-i18n="export.visible">Export visible</button>
42
43
  </div>
43
44
 
44
45
  <div class="queue-controls">
package/ui/styles.css CHANGED
@@ -210,6 +210,28 @@ summary:focus-visible,
210
210
  border-bottom: 1px solid var(--line);
211
211
  }
212
212
 
213
+ .export-button {
214
+ min-height: 36px;
215
+ margin-left: auto;
216
+ border: 1px solid var(--line-strong);
217
+ border-radius: 4px;
218
+ padding: 0 10px;
219
+ background: var(--paper);
220
+ color: var(--muted);
221
+ font-size: 12px;
222
+ font-weight: 650;
223
+ }
224
+
225
+ .export-button:hover:not(:disabled) {
226
+ color: var(--ink);
227
+ background: #edf0f2;
228
+ }
229
+
230
+ .export-button:disabled {
231
+ cursor: not-allowed;
232
+ opacity: 0.5;
233
+ }
234
+
213
235
  .section-label,
214
236
  .state-code {
215
237
  margin: 0 0 3px;