runledger-cli 0.1.0__py3-none-any.whl

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.
cli/ledger.py ADDED
@@ -0,0 +1,1276 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ RunLedger CLI – zero‑friction experiment search.
4
+
5
+ Usage:
6
+ python ledger.py scan /path/to/folder [--batch NAME] [--jsonl]
7
+ python ledger.py search --metric <key> --op <gt|lt|eq|...> --value <v> [--batch NAME] [--summary] [--save <name>]
8
+ python ledger.py search --metric <key1> --op <op1> --value <v1> --metric <key2> --op <op2> --value <v2> [--combine and|or]
9
+ python ledger.py search --q <phrase> [--fuzzy] [--batch NAME] [--summary]
10
+ python ledger.py search --saved <name>
11
+ python ledger.py export ... (same filters as search)
12
+ python ledger.py preview --metric <key> --op <gt|lt|eq|...> --value <v> [--batch NAME]
13
+ python ledger.py metrics [--batch NAME] [--verbose]
14
+ python ledger.py interactive [--batch NAME]
15
+ python ledger.py diff <id1> <id2> [--history] [--batch BATCH] [--json]
16
+ python ledger.py aggregate --metric <key> --agg <AVG|MAX|MIN|SUM|COUNT> [--group-by <field>] [--batch NAME] [--json]
17
+ python ledger.py saved list [--batch]
18
+ python ledger.py saved delete <name> [--batch]
19
+ python ledger.py stop
20
+ python ledger.py status
21
+ """
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import sys
27
+ import subprocess
28
+ import time
29
+ from pathlib import Path
30
+ from typing import Any, Dict
31
+
32
+ import requests
33
+ from rich.console import Console
34
+ from rich.panel import Panel
35
+ from rich.progress import Progress
36
+ from rich.prompt import Prompt, Confirm
37
+ from rich.table import Table
38
+
39
+ # ------------------------------------------------------------
40
+ # Force UTF‑8 output so Rich can render box‑drawing characters
41
+ # ------------------------------------------------------------
42
+ try:
43
+ sys.stdout.reconfigure(encoding='utf-8')
44
+ except Exception:
45
+ pass
46
+
47
+ # ------------------------------------------------------------
48
+ # Configuration
49
+ # ------------------------------------------------------------
50
+ API_BASE = "http://localhost:8080/api/runs"
51
+ COMPOSE_FILE = os.path.join(os.path.dirname(__file__), "..", "docker-compose.yml")
52
+ HEALTH_URL = "http://localhost:8080/actuator/health"
53
+ PAGE_SIZE = 10
54
+
55
+ console = Console()
56
+
57
+ # ------------------------------------------------------------
58
+ # Helpers
59
+ # ------------------------------------------------------------
60
+ def check_docker():
61
+ result = subprocess.run("docker version", shell=True,
62
+ capture_output=True, text=True)
63
+ if result.returncode != 0:
64
+ console.print("[bold red]Docker is not installed or not reachable.[/bold red]")
65
+ console.print("Please install Docker Desktop from [link]https://www.docker.com/get-started[/link]")
66
+ return False
67
+ return True
68
+
69
+ def backend_running():
70
+ try:
71
+ resp = requests.get(HEALTH_URL, timeout=2)
72
+ return resp.status_code == 200
73
+ except requests.RequestException:
74
+ return False
75
+
76
+ def wait_for_backend(timeout=60):
77
+ console.print("Waiting for the RunLedger backend to be ready...", end="")
78
+ start = time.time()
79
+ while time.time() - start < timeout:
80
+ if backend_running():
81
+ console.print("[green] ready![/green]")
82
+ return True
83
+ time.sleep(2)
84
+ console.print(".", end="")
85
+ console.print("\n[red]Timed out waiting for backend.[/red]")
86
+ return False
87
+
88
+ def start_backend():
89
+ if not os.path.exists(COMPOSE_FILE):
90
+ console.print("[red]docker-compose.yml not found. Are you in the project root?[/red]")
91
+ sys.exit(1)
92
+ console.print("Starting RunLedger backend...")
93
+ subprocess.run(f"docker compose -f {COMPOSE_FILE} up -d", shell=True, check=True)
94
+ if wait_for_backend():
95
+ console.print("[green]RunLedger backend is running.[/green]")
96
+ else:
97
+ console.print("[red]Failed to start RunLedger. Check Docker logs.[/red]")
98
+ sys.exit(1)
99
+
100
+ def stop_backend():
101
+ if os.path.exists(COMPOSE_FILE):
102
+ subprocess.run(f"docker compose -f {COMPOSE_FILE} down", shell=True, check=True)
103
+ console.print("RunLedger backend stopped.")
104
+ else:
105
+ console.print("[red]Cannot find docker-compose.yml[/red]")
106
+
107
+ # ------------------------------------------------------------
108
+ # Ingestion
109
+ # ------------------------------------------------------------
110
+ def ingest_folder(folder: str, batch: str = None, jsonl: bool = False):
111
+ path = Path(folder).expanduser()
112
+ if not path.exists():
113
+ console.print(f"[red]Folder not found: {folder}[/red]")
114
+ return
115
+
116
+ if batch is None:
117
+ batch = path.name
118
+
119
+ json_files = list(path.glob("*.json")) if not jsonl else list(path.glob("*.jsonl")) + list(path.glob("*.json"))
120
+ if not json_files:
121
+ console.print(f"[yellow]No .json files found in {folder}[/yellow]")
122
+ return
123
+
124
+ if jsonl:
125
+ console.print(f"Found {len(json_files)} JSONL file(s) in '{batch}'. Ingesting line by line...")
126
+ else:
127
+ console.print(f"Found {len(json_files)} JSON file(s) in '{batch}'. Ingesting...")
128
+
129
+ count = 0
130
+ with Progress() as progress:
131
+ task = progress.add_task("[cyan]Ingesting...", total=len(json_files))
132
+ for jfile in json_files:
133
+ progress.update(task, description=f"Ingesting {jfile.name}...")
134
+ try:
135
+ with open(jfile, "r", encoding="utf-8") as fh:
136
+ if jsonl:
137
+ for idx, line in enumerate(fh):
138
+ line = line.strip()
139
+ if not line:
140
+ continue
141
+ try:
142
+ run = json.loads(line)
143
+ except json.JSONDecodeError as e:
144
+ console.print(f"[red]Skipping {jfile.name} line {idx+1}: {e}[/red]")
145
+ continue
146
+ payload = {
147
+ "payload": run,
148
+ "batch": batch,
149
+ }
150
+ payload["payload"]["_source"] = {
151
+ "file": jfile.name,
152
+ "index": idx,
153
+ }
154
+ try:
155
+ resp = requests.post(API_BASE, json=payload)
156
+ if resp.status_code == 201:
157
+ count += 1
158
+ else:
159
+ console.print(f"[red]Failed to ingest line {idx+1} from {jfile.name}: {resp.status_code}[/red]")
160
+ except requests.RequestException as e:
161
+ console.print(f"[red]Error sending {jfile.name}: {e}[/red]")
162
+ break
163
+ else:
164
+ data = json.load(fh)
165
+ if isinstance(data, dict):
166
+ data = [data]
167
+ for idx, run in enumerate(data):
168
+ payload = {
169
+ "payload": run,
170
+ "batch": batch,
171
+ }
172
+ payload["payload"]["_source"] = {
173
+ "file": jfile.name,
174
+ "index": idx,
175
+ }
176
+ try:
177
+ resp = requests.post(API_BASE, json=payload)
178
+ if resp.status_code == 201:
179
+ count += 1
180
+ else:
181
+ console.print(f"[red]Failed to ingest run {idx} from {jfile.name}: {resp.status_code}[/red]")
182
+ except requests.RequestException as e:
183
+ console.print(f"[red]Error sending {jfile.name}: {e}[/red]")
184
+ break
185
+ except Exception as e:
186
+ console.print(f"[red]Skipping {jfile.name}: {e}[/red]")
187
+ continue
188
+ progress.advance(task)
189
+
190
+ console.print(f"[green]Successfully ingested {count} run(s) into batch '{batch}'.[/green]")
191
+ if jsonl:
192
+ console.print("[dim]Tip: JSONL ingestion creates one run per line. Use --jsonl flag to process line‑delimited files.[/dim]")
193
+
194
+ # ------------------------------------------------------------
195
+ # API wrappers
196
+ # ------------------------------------------------------------
197
+ def fetch_metrics(batch: str = None) -> list:
198
+ params = {"batch": batch} if batch else {}
199
+ try:
200
+ resp = requests.get(f"{API_BASE}/metrics", params=params)
201
+ if resp.status_code == 200:
202
+ return resp.json()
203
+ else:
204
+ console.print(f"[red]Failed to fetch metrics (HTTP {resp.status_code}).[/red]")
205
+ return []
206
+ except requests.RequestException as e:
207
+ console.print(f"[red]Error fetching metrics: {e}[/red]")
208
+ return []
209
+
210
+ def fetch_verbose_metrics(batch: str) -> list:
211
+ params = {"batch": batch, "verbose": "true"}
212
+ try:
213
+ resp = requests.get(f"{API_BASE}/metrics", params=params)
214
+ if resp.status_code == 200:
215
+ return resp.json()
216
+ else:
217
+ console.print(f"[red]Failed to fetch verbose metrics (HTTP {resp.status_code}).[/red]")
218
+ return []
219
+ except requests.RequestException as e:
220
+ console.print(f"[red]Error fetching verbose metrics: {e}[/red]")
221
+ return []
222
+
223
+ def perform_search(metric=None, op=None, value=None, q=None, fuzzy=False,
224
+ batch=None, page=0, size=PAGE_SIZE, pointers=True):
225
+ """Single‑condition GET search. Never sends block – CLI handles truncation."""
226
+ params = {
227
+ "page": page,
228
+ "size": size,
229
+ "sort": "created_at,desc",
230
+ }
231
+ if batch:
232
+ params["batch"] = batch
233
+ if q:
234
+ params["q"] = q
235
+ if fuzzy:
236
+ params["fuzzy"] = "true"
237
+ elif metric and op and value is not None:
238
+ params["metric"] = metric
239
+ params["op"] = op
240
+ params["value"] = value
241
+ if pointers:
242
+ params["pointers"] = "true"
243
+ else:
244
+ raise ValueError("Either --q or (--metric, --op, --value) must be provided")
245
+
246
+ try:
247
+ resp = requests.get(API_BASE, params=params)
248
+ if resp.status_code == 200:
249
+ return resp.json()
250
+ else:
251
+ console.print(f"[red]Search failed (HTTP {resp.status_code}).[/red]")
252
+ return None
253
+ except requests.RequestException as e:
254
+ console.print(f"[red]Error searching: {e}[/red]")
255
+ return None
256
+
257
+ def perform_multi_search(filters, combine, batch=None, page=0, size=PAGE_SIZE):
258
+ """Multi‑condition POST search. No block parameter."""
259
+ body = {
260
+ "filters": filters,
261
+ "combine": combine,
262
+ }
263
+ if batch:
264
+ body["batch"] = batch
265
+
266
+ try:
267
+ resp = requests.post(f"{API_BASE}/search", json=body)
268
+ if resp.status_code == 200:
269
+ return resp.json()
270
+ else:
271
+ console.print(f"[red]Multi‑search failed (HTTP {resp.status_code}).[/red]")
272
+ return None
273
+ except requests.RequestException as e:
274
+ console.print(f"[red]Error in multi‑search: {e}[/red]")
275
+ return None
276
+
277
+ def save_search(name, batch, params):
278
+ body = {"name": name, "batch": batch, "paramsJson": json.dumps(params)}
279
+ resp = requests.post("http://localhost:8080/api/saved", json=body)
280
+ if resp.status_code != 200:
281
+ console.print(f"[red]Failed to save search: {resp.text}[/red]")
282
+ return False
283
+ return True
284
+
285
+ def get_saved_search(name, batch):
286
+ params = {"batch": batch} if batch else {}
287
+ resp = requests.get(f"http://localhost:8080/api/saved/{name}", params=params)
288
+ if resp.status_code == 200:
289
+ return resp.json()
290
+ return None
291
+
292
+ def list_saved_searches(batch):
293
+ params = {"batch": batch} if batch else {}
294
+ resp = requests.get("http://localhost:8080/api/saved", params=params)
295
+ if resp.status_code == 200:
296
+ return resp.json()
297
+ return []
298
+
299
+ def delete_saved_search(name, batch):
300
+ params = {"batch": batch} if batch else {}
301
+ resp = requests.delete(f"http://localhost:8080/api/saved/{name}", params=params)
302
+ return resp.status_code == 204
303
+
304
+ # ------------------------------------------------------------
305
+ # Block display helpers
306
+ # ------------------------------------------------------------
307
+ def navigate_json_pointer(doc, pointer: str):
308
+ parts = pointer.lstrip("/").split("/")
309
+ cur = doc
310
+ for p in parts:
311
+ if p == "":
312
+ continue
313
+ if isinstance(cur, dict):
314
+ cur = cur.get(p, None)
315
+ if cur is None:
316
+ return None
317
+ else:
318
+ return None
319
+ return cur
320
+
321
+ def show_blocks(run: dict, dot_path: str = None, block_levels: list = None):
322
+ payload = run.get("payload", {})
323
+ if isinstance(payload, dict):
324
+ source_file = payload.get("_source", {}).get("file") or payload.get("experiment") or f"run-{run['id']}"
325
+ else:
326
+ source_file = f"run-{run['id']}"
327
+
328
+ matched = run.get("matched")
329
+ if matched:
330
+ console.print(" Matches:")
331
+ for m in matched:
332
+ val = m.get("value")
333
+ if isinstance(val, float):
334
+ val_str = f"{val:.6f}"
335
+ else:
336
+ val_str = str(val)
337
+ console.print(f" • {m['pointer']} = {val_str}")
338
+ console.print()
339
+
340
+ if dot_path is None:
341
+ label = "-- full run (full‑text/fuzzy match)"
342
+ try:
343
+ pretty = json.dumps(payload, indent=2)
344
+ except Exception:
345
+ pretty = str(payload)
346
+ console.print(Panel(pretty, title=f"Run {run['id']} · {source_file}"))
347
+ return
348
+
349
+ parts = [seg.replace("[]", "") for seg in dot_path.split(".")]
350
+ max_block = len(parts) - 1
351
+
352
+ if block_levels is None:
353
+ levels = list(range(max_block + 1))
354
+ else:
355
+ levels = [l for l in block_levels if 0 <= l <= max_block]
356
+ if not levels:
357
+ console.print(f"[yellow]Requested block depth(s) out of range. Max depth is {max_block}.[/yellow]")
358
+ return
359
+
360
+ for block in sorted(levels, reverse=True):
361
+ if block == 0 and matched:
362
+ for i, m in enumerate(matched):
363
+ snippet = m.get("snippet")
364
+ try:
365
+ pretty = json.dumps(snippet, indent=2)
366
+ except Exception:
367
+ pretty = str(snippet)
368
+ title = f"Run {run['id']} · {source_file} | metric: {dot_path} | depth: 0 (match {i+1}/{len(matched)})"
369
+ console.print(Panel(pretty, title=title))
370
+ else:
371
+ ancestor_parts = parts[:len(parts) - (block + 1)]
372
+ pointer = "/" + "/".join(ancestor_parts) if ancestor_parts else ""
373
+ node = navigate_json_pointer(payload, pointer) if pointer else payload
374
+ depth_label = ""
375
+ if block == max_block:
376
+ depth_label = " (full run)"
377
+ elif block == 0:
378
+ depth_label = " (tightest match)"
379
+ try:
380
+ pretty = json.dumps(node, indent=2) if node is not None else "null"
381
+ except Exception:
382
+ pretty = str(node)
383
+ console.print(Panel(pretty, title=f"Run {run['id']} · {source_file} | metric: {dot_path} | depth: {block}{depth_label}"))
384
+
385
+ # ------------------------------------------------------------
386
+ # Export helpers
387
+ # ------------------------------------------------------------
388
+ def build_export_blocks(content, dot_path, block_levels, include_pointers=True):
389
+ blocks = []
390
+ for run in content:
391
+ payload = run.get("payload", {})
392
+ if isinstance(payload, dict):
393
+ source_file = payload.get("_source", {}).get("file") or payload.get("experiment") or f"run-{run['id']}"
394
+ else:
395
+ source_file = f"run-{run['id']}"
396
+
397
+ matched = run.get("matched") if include_pointers else None
398
+
399
+ if dot_path is None:
400
+ blocks.append({
401
+ "id": run["id"],
402
+ "sourceFile": source_file,
403
+ "metric": "(full‑text/fuzzy match)",
404
+ "depth": "full",
405
+ "content": payload,
406
+ "matches": matched
407
+ })
408
+ continue
409
+
410
+ parts = [seg.replace("[]", "") for seg in dot_path.split(".")]
411
+ max_block = len(parts) - 1
412
+
413
+ levels = block_levels if block_levels is not None else list(range(max_block + 1))
414
+ levels = [l for l in levels if 0 <= l <= max_block]
415
+ if not levels:
416
+ levels = [max_block]
417
+
418
+ for block in sorted(levels, reverse=True):
419
+ if block == 0 and matched:
420
+ for i, m in enumerate(matched):
421
+ snippet = m.get("snippet")
422
+ blocks.append({
423
+ "id": run["id"],
424
+ "sourceFile": source_file,
425
+ "metric": dot_path,
426
+ "depth": 0,
427
+ "matchIndex": i + 1,
428
+ "totalMatches": len(matched),
429
+ "content": snippet,
430
+ "pointer": m.get("pointer"),
431
+ "value": m.get("value")
432
+ })
433
+ else:
434
+ ancestor_parts = parts[:len(parts) - (block + 1)]
435
+ pointer = "/" + "/".join(ancestor_parts) if ancestor_parts else ""
436
+ node = navigate_json_pointer(payload, pointer) if pointer else payload
437
+ blocks.append({
438
+ "id": run["id"],
439
+ "sourceFile": source_file,
440
+ "metric": dot_path,
441
+ "depth": block,
442
+ "content": node,
443
+ "matches": matched if block != 0 else None
444
+ })
445
+ return blocks
446
+
447
+ def format_blocks_as_text(blocks):
448
+ lines = []
449
+ for i, block in enumerate(blocks):
450
+ if i > 0:
451
+ lines.append("")
452
+ lines.append("-" * 70)
453
+ lines.append(f"id: {block['id']}")
454
+ lines.append(f"file: {block['sourceFile']}")
455
+ if block.get("metric"):
456
+ lines.append(f"metric: {block['metric']}")
457
+ depth = block.get("depth")
458
+ if depth is not None:
459
+ depth_str = str(depth)
460
+ if block.get("matchIndex"):
461
+ depth_str += f" (match {block['matchIndex']}/{block['totalMatches']})"
462
+ lines.append(f"depth: {depth_str}")
463
+ pointer = block.get("pointer")
464
+ if pointer:
465
+ val = block.get("value")
466
+ if isinstance(val, float):
467
+ val_str = f"{val:.6f}"
468
+ else:
469
+ val_str = str(val)
470
+ lines.append(f"match: {pointer} = {val_str}")
471
+ matches = block.get("matches")
472
+ if matches:
473
+ lines.append("matches:")
474
+ for m in matches:
475
+ val = m.get("value")
476
+ if isinstance(val, float):
477
+ val_str = f"{val:.6f}"
478
+ else:
479
+ val_str = str(val)
480
+ lines.append(f" • {m['pointer']} = {val_str}")
481
+ lines.append("-" * 70)
482
+ lines.append(json.dumps(block["content"], indent=2))
483
+ return "\n".join(lines) + "\n"
484
+
485
+ def suggest_array_path(batch, shorthand):
486
+ try:
487
+ resp = requests.get(f"{API_BASE}/metrics", params={"batch": batch, "verbose": "true"})
488
+ if resp.status_code != 200:
489
+ return None
490
+ entries = resp.json()
491
+ scalar_paths = [e["path"] for e in entries if e["key"] == shorthand and "[]" not in e["path"]]
492
+ if scalar_paths:
493
+ return None
494
+ array_paths = [e["path"] for e in entries if e["key"] == shorthand and "[]" in e["path"]]
495
+ if array_paths:
496
+ best = min(array_paths, key=len)
497
+ return f"Hint: '{shorthand}' exists inside arrays. Did you mean: {best}?"
498
+ except Exception:
499
+ pass
500
+ return None
501
+
502
+ # ------------------------------------------------------------
503
+ # Flatten and diff helpers
504
+ # ------------------------------------------------------------
505
+ def flatten_payload(payload: Any, prefix: str = '') -> Dict[str, Any]:
506
+ result = {}
507
+ if isinstance(payload, dict):
508
+ for k, v in payload.items():
509
+ if k == '_source':
510
+ continue
511
+ path = f"{prefix}.{k}" if prefix else k
512
+ if isinstance(v, (dict, list)):
513
+ result.update(flatten_payload(v, path))
514
+ else:
515
+ result[path] = v
516
+ elif isinstance(payload, list):
517
+ for i, item in enumerate(payload):
518
+ path = f"{prefix}[{i}]"
519
+ if isinstance(item, (dict, list)):
520
+ result.update(flatten_payload(item, path))
521
+ else:
522
+ result[path] = item
523
+ else:
524
+ result[prefix] = payload
525
+ return result
526
+
527
+ def compute_delta(val1, val2):
528
+ try:
529
+ v1 = float(val1)
530
+ v2 = float(val2)
531
+ delta = v2 - v1
532
+ if delta > 0:
533
+ return f"+{delta:.6f}", "green"
534
+ elif delta < 0:
535
+ return f"{delta:.6f}", "red"
536
+ else:
537
+ return "0", "white"
538
+ except (ValueError, TypeError):
539
+ if val1 != val2:
540
+ return "changed", "yellow"
541
+ return "unchanged", "white"
542
+
543
+ # ------------------------------------------------------------
544
+ # Non‑interactive CLI outputs
545
+ # ------------------------------------------------------------
546
+ def cli_metrics(args):
547
+ if args.verbose:
548
+ entries = fetch_verbose_metrics(args.batch)
549
+ if not entries:
550
+ console.print("[yellow]No metrics found.[/yellow]")
551
+ return
552
+ table = Table(title="Verbose Metrics")
553
+ table.add_column("Shorthand Key", style="cyan")
554
+ table.add_column("Full Dot‑Path", style="green")
555
+ table.add_column("Depth", justify="right")
556
+ for item in entries:
557
+ table.add_row(item["key"], item["path"], str(item["depth"]))
558
+ console.print(table)
559
+ else:
560
+ keys = fetch_metrics(args.batch)
561
+ print(json.dumps(keys))
562
+
563
+ def cli_search(args):
564
+ if args.saved:
565
+ saved = get_saved_search(args.saved, args.batch)
566
+ if not saved:
567
+ console.print(f"[red]Saved search '{args.saved}' not found.[/red]")
568
+ sys.exit(1)
569
+ params = json.loads(saved["paramsJson"])
570
+ args.metrics = params.get("metrics", [])
571
+ args.ops = params.get("ops", [])
572
+ args.values = params.get("values", [])
573
+ args.combine = params.get("combine", "and")
574
+ args.batch = params.get("batch", args.batch)
575
+
576
+ filters = None
577
+ if args.metrics and args.ops and args.values:
578
+ if len(args.metrics) != len(args.ops) or len(args.metrics) != len(args.values):
579
+ console.print("[red]Number of --metric, --op, --value arguments must match.[/red]")
580
+ sys.exit(1)
581
+ filters = [
582
+ {"metric": m, "op": op, "value": v}
583
+ for m, op, v in zip(args.metrics, args.ops, args.values)
584
+ ]
585
+
586
+ if args.save:
587
+ if not filters:
588
+ console.print("[red]No search parameters to save.[/red]")
589
+ sys.exit(1)
590
+ save_params = {
591
+ "metrics": [f["metric"] for f in filters],
592
+ "ops": [f["op"] for f in filters],
593
+ "values": [f["value"] for f in filters],
594
+ "combine": args.combine or "and",
595
+ "batch": args.batch
596
+ }
597
+ if save_search(args.save, args.batch, save_params):
598
+ console.print(f"[green]Saved search '{args.save}'.[/green]")
599
+ return
600
+
601
+ pointers_enabled = getattr(args, "pointers", True)
602
+ combine = args.combine if args.combine else "and"
603
+
604
+ if args.q:
605
+ data = perform_search(q=args.q, fuzzy=args.fuzzy, batch=args.batch, page=0)
606
+ elif filters and len(filters) > 1:
607
+ data = perform_multi_search(filters, combine, batch=args.batch, page=0)
608
+ elif filters:
609
+ f = filters[0]
610
+ data = perform_search(metric=f["metric"], op=f["op"], value=f["value"],
611
+ batch=args.batch, page=0, pointers=pointers_enabled)
612
+ else:
613
+ console.print("[red]No search parameters provided.[/red]")
614
+ sys.exit(1)
615
+
616
+ if data is None:
617
+ sys.exit(1)
618
+
619
+ if args.summary:
620
+ content = data.get("content", [])
621
+ if not content:
622
+ print("No matching runs found.")
623
+ if args.batch and filters and len(filters) == 1:
624
+ metric = filters[0]["metric"]
625
+ if "." not in metric and "[]" not in metric:
626
+ hint = suggest_array_path(args.batch, metric)
627
+ if hint:
628
+ print(hint)
629
+ return
630
+ table = Table(title="Search Results (summary)")
631
+ table.add_column("ID", justify="right", style="cyan")
632
+ table.add_column("Source File", justify="left", style="green")
633
+ for run in content:
634
+ run_id = run["id"]
635
+ payload = run.get("payload", {})
636
+ source_file = payload.get("experiment") or payload.get("_source", {}).get("file") or f"run-{run_id}"
637
+ table.add_row(str(run_id), source_file)
638
+ console.print(table)
639
+ return
640
+
641
+ if args.json:
642
+ print(json.dumps(data, indent=2))
643
+ return
644
+
645
+ content = data.get("content", [])
646
+ if not content:
647
+ print("No matching runs found.")
648
+ if args.batch and filters and len(filters) == 1:
649
+ metric = filters[0]["metric"]
650
+ if "." not in metric and "[]" not in metric:
651
+ hint = suggest_array_path(args.batch, metric)
652
+ if hint:
653
+ print(hint)
654
+ return
655
+
656
+ dot_path = None
657
+ if not args.q and filters and len(filters) == 1:
658
+ metric = filters[0]["metric"]
659
+ if "." in metric or "[]" in metric:
660
+ dot_path = metric
661
+ else:
662
+ first_payload = content[0].get("payload", {})
663
+ dot_path = find_shallowest_path(first_payload, metric)
664
+
665
+ block_levels = None
666
+ if args.block is not None:
667
+ block_levels = [args.block]
668
+
669
+ for run in content:
670
+ show_blocks(run, dot_path, block_levels)
671
+
672
+ def cli_export(args):
673
+ filters = None
674
+ if args.metrics and args.ops and args.values:
675
+ if len(args.metrics) != len(args.ops) or len(args.metrics) != len(args.values):
676
+ console.print("[red]Number of --metric, --op, --value arguments must match.[/red]")
677
+ sys.exit(1)
678
+ filters = [
679
+ {"metric": m, "op": op, "value": v}
680
+ for m, op, v in zip(args.metrics, args.ops, args.values)
681
+ ]
682
+
683
+ pointers_enabled = getattr(args, "pointers", True)
684
+ combine = args.combine if args.combine else "and"
685
+
686
+ if args.q:
687
+ data = perform_search(q=args.q, fuzzy=args.fuzzy, batch=args.batch, page=0)
688
+ elif filters and len(filters) > 1:
689
+ data = perform_multi_search(filters, combine, batch=args.batch, page=0)
690
+ elif filters:
691
+ f = filters[0]
692
+ data = perform_search(metric=f["metric"], op=f["op"], value=f["value"],
693
+ batch=args.batch, page=0, pointers=pointers_enabled)
694
+ else:
695
+ console.print("[red]No search parameters provided.[/red]")
696
+ sys.exit(1)
697
+
698
+ if data is None:
699
+ sys.exit(1)
700
+
701
+ content = data.get("content", [])
702
+ if not content:
703
+ if args.output:
704
+ Path(args.output).write_text("", encoding="utf-8")
705
+ else:
706
+ print("")
707
+ return
708
+
709
+ dot_path = None
710
+ if not args.q and filters and len(filters) == 1:
711
+ metric = filters[0]["metric"]
712
+ if "." in metric or "[]" in metric:
713
+ dot_path = metric
714
+ else:
715
+ first_payload = content[0].get("payload", {})
716
+ dot_path = find_shallowest_path(first_payload, metric)
717
+
718
+ block_levels = None
719
+ if args.block is not None:
720
+ block_levels = [args.block]
721
+
722
+ blocks = build_export_blocks(content, dot_path, block_levels, include_pointers=pointers_enabled)
723
+
724
+ if args.format == "json":
725
+ out = json.dumps(blocks, indent=2)
726
+ else:
727
+ out = format_blocks_as_text(blocks)
728
+
729
+ if args.output:
730
+ Path(args.output).write_text(out, encoding="utf-8")
731
+ console.print(f"[green]Exported {len(blocks)} block(s) to {args.output}[/green]")
732
+ else:
733
+ print(out)
734
+
735
+ def cli_preview(args):
736
+ pointers_enabled = getattr(args, "pointers", True)
737
+ data = perform_search(metric=args.metric, op=args.op, value=args.value,
738
+ batch=args.batch, page=0, size=1,
739
+ pointers=pointers_enabled)
740
+ if data is None or not data.get("content"):
741
+ console.print("[red]No matching run found.[/red]")
742
+ sys.exit(1)
743
+
744
+ run = data["content"][0]
745
+ metric = args.metric
746
+ if "." in metric or "[]" in metric:
747
+ dot_path = metric
748
+ else:
749
+ dot_path = find_shallowest_path(run.get("payload", {}), metric)
750
+ if dot_path is None:
751
+ console.print(f"[red]Could not locate key '{metric}' in the matching run.[/red]")
752
+ sys.exit(1)
753
+ show_blocks(run, dot_path)
754
+
755
+ def find_shallowest_path(payload: dict, key: str):
756
+ def _walk(prefix, node, depth):
757
+ if isinstance(node, dict):
758
+ for k, v in node.items():
759
+ path = f"{prefix}.{k}" if prefix else k
760
+ if k == key and not isinstance(v, dict):
761
+ return (depth, path)
762
+ if isinstance(v, dict):
763
+ res = _walk(path, v, depth+1)
764
+ if res:
765
+ return res
766
+ return None
767
+ return None
768
+ result = _walk("", payload, 0)
769
+ return result[1] if result else None
770
+
771
+ # ------------------------------------------------------------
772
+ # Interactive search loop
773
+ # ------------------------------------------------------------
774
+ def interactive_search(batch: str = None):
775
+ console.print("\n[bold]Interactive Search[/bold]")
776
+ while True:
777
+ metrics = fetch_metrics(batch)
778
+ if not metrics:
779
+ console.print("[yellow]No metrics available yet. Ingest some runs first.[/yellow]")
780
+ break
781
+
782
+ selected_metric = None
783
+ filtered = metrics[:]
784
+ while True:
785
+ if len(filtered) == 1:
786
+ console.print(f"\n[green]Auto‑selected metric: {filtered[0]}[/green]")
787
+ selected_metric = filtered[0]
788
+ break
789
+ if len(filtered) == 0:
790
+ console.print("[red]No matching metrics. Try a different filter.[/red]")
791
+ filtered = metrics[:]
792
+
793
+ table = Table(title="Metrics (type to filter, press Enter for all)")
794
+ table.add_column("#", justify="right")
795
+ table.add_column("Metric Name", style="cyan")
796
+ for i, m in enumerate(filtered, 1):
797
+ table.add_row(str(i), m)
798
+ console.print(table)
799
+
800
+ query = Prompt.ask("Type to filter metrics (or 'all' to reset, number to select)", default="")
801
+ if query.lower() == "all":
802
+ filtered = metrics[:]
803
+ continue
804
+ if query.isdigit():
805
+ idx = int(query) - 1
806
+ if 0 <= idx < len(filtered):
807
+ selected_metric = filtered[idx]
808
+ break
809
+ filtered = [m for m in metrics if query.lower() in m.lower()]
810
+
811
+ metric = selected_metric
812
+
813
+ console.print("\nOperators:")
814
+ console.print(" > (greater than)")
815
+ console.print(" >= (greater than or equal)")
816
+ console.print(" < (less than)")
817
+ console.print(" <= (less than or equal)")
818
+ console.print(" = (equal)")
819
+ op_map = {
820
+ ">": "gt", ">=": "gte", "<": "lt", "<=": "lte", "=": "eq",
821
+ "gt": "gt", "gte": "gte", "lt": "lt", "lte": "lte", "eq": "eq",
822
+ }
823
+ op_input = Prompt.ask("Operator", choices=list(op_map.keys()), default=">")
824
+ op = op_map[op_input]
825
+ value = Prompt.ask("Value")
826
+
827
+ page = 0
828
+ while True:
829
+ data = perform_search(metric=metric, op=op, value=value, batch=batch, page=page)
830
+ if data is None:
831
+ break
832
+ display_results(data)
833
+ content = data.get("content", [])
834
+ total_pages = data.get("totalPages", 1)
835
+ if not content or total_pages <= 1:
836
+ break
837
+ cmd = Prompt.ask("Navigation (n/p/q)", choices=["n", "p", "q"], default="q")
838
+ if cmd == "n" and page < total_pages - 1:
839
+ page += 1
840
+ elif cmd == "p" and page > 0:
841
+ page -= 1
842
+ elif cmd == "q":
843
+ break
844
+
845
+ again = Prompt.ask("Another search? (y/n)", choices=["y", "n"], default="n")
846
+ if again != "y":
847
+ break
848
+
849
+ def display_results(data):
850
+ if data is None:
851
+ return
852
+ content = data.get("content", [])
853
+ total_elements = data.get("totalElements", 0)
854
+ total_pages = data.get("totalPages", 1)
855
+ page_number = data.get("pageable", {}).get("pageNumber", 0)
856
+
857
+ if not content:
858
+ console.print("No matching runs found.")
859
+ return
860
+
861
+ table = Table(title=f"Search Results (page {page_number+1}/{total_pages})")
862
+ table.add_column("ID", justify="right", style="cyan")
863
+ table.add_column("Created At", justify="left", style="green")
864
+ table.add_column("Experiment / Source", justify="left")
865
+ table.add_column("Snippet", justify="left", no_wrap=False, max_width=60)
866
+
867
+ for run in content:
868
+ run_id = run["id"]
869
+ created = run["createdAt"]
870
+ payload = run.get("payload", {})
871
+ experiment = payload.get("experiment", "")
872
+ source = payload.get("_source", {}).get("file", "")
873
+ description = experiment or source or "-"
874
+ snippet = json.dumps(payload, indent=2)
875
+ if len(snippet) > 120:
876
+ snippet = snippet[:120] + "..."
877
+ table.add_row(str(run_id), created, description, snippet)
878
+
879
+ console.print(table)
880
+ if total_pages > 1:
881
+ nav = f"Page {page_number+1} of {total_pages} | n = next page, p = previous page, q = quit"
882
+ console.print(nav)
883
+
884
+ # ------------------------------------------------------------
885
+ # Status
886
+ # ------------------------------------------------------------
887
+ def show_status():
888
+ if backend_running():
889
+ console.print("[green]RunLedger backend is running.[/green]")
890
+ try:
891
+ resp = requests.get(f"{API_BASE}/metrics")
892
+ if resp.status_code == 200:
893
+ metrics = resp.json()
894
+ console.print(f"Available metrics across all batches: {', '.join(metrics) if metrics else 'none'}")
895
+ except Exception:
896
+ pass
897
+ else:
898
+ console.print("[red]RunLedger backend is not running.[/red]")
899
+
900
+ # ------------------------------------------------------------
901
+ # Diff
902
+ # ------------------------------------------------------------
903
+ def cli_diff(args):
904
+ if args.history:
905
+ batch = args.batch
906
+ if not batch:
907
+ console.print("[red]--batch is required with --history[/red]")
908
+ sys.exit(1)
909
+
910
+ resp = requests.get(f"{API_BASE}/{args.id1}")
911
+ if resp.status_code != 200:
912
+ console.print("[red]Could not fetch run. Check ID.[/red]")
913
+ sys.exit(1)
914
+ run = resp.json()
915
+ payload = run.get("payload", {})
916
+ source_file = payload.get("_source", {}).get("file", "")
917
+ source_index = payload.get("_source", {}).get("index", 0)
918
+
919
+ resp = requests.get(f"{API_BASE}/versions", params={
920
+ "batch": batch, "sourceFile": source_file, "sourceIndex": source_index
921
+ })
922
+ if resp.status_code != 200 or not resp.json():
923
+ console.print("[red]No versions found.[/red]")
924
+ sys.exit(1)
925
+ versions = resp.json()
926
+
927
+ if len(versions) < 2:
928
+ console.print("[yellow]Only one version exists – nothing to diff.[/yellow]")
929
+ return
930
+
931
+ for i in range(len(versions) - 1):
932
+ v1 = versions[i]
933
+ v2 = versions[i+1]
934
+ flat1 = flatten_payload(v1.get("payload", {}))
935
+ flat2 = flatten_payload(v2.get("payload", {}))
936
+ all_keys = sorted(set(flat1) | set(flat2))
937
+
938
+ table = Table(title=f"Version Diff: v{v1['id']} → v{v2['id']}")
939
+ table.add_column("Metric / Parameter", style="cyan")
940
+ table.add_column(f"v{v1['id']}", style="white")
941
+ table.add_column(f"v{v2['id']}", style="white")
942
+ table.add_column("Δ", style="white")
943
+
944
+ for key in all_keys:
945
+ v1_val = flat1.get(key, "—")
946
+ v2_val = flat2.get(key, "—")
947
+ delta_str, color = compute_delta(v1_val, v2_val)
948
+ style_prefix = f"[{color}]" if color != "white" else ""
949
+ style_suffix = "[/]" if style_prefix else ""
950
+ table.add_row(
951
+ f"{style_prefix}{key}{style_suffix}",
952
+ f"{style_prefix}{v1_val}{style_suffix}",
953
+ f"{style_prefix}{v2_val}{style_suffix}",
954
+ f"{style_prefix}{delta_str}{style_suffix}"
955
+ )
956
+ console.print(table)
957
+ return
958
+
959
+ if args.id2 is None:
960
+ console.print("[red]Second run ID is required (or use --history).[/red]")
961
+ sys.exit(1)
962
+
963
+ resp1 = requests.get(f"{API_BASE}/{args.id1}")
964
+ resp2 = requests.get(f"{API_BASE}/{args.id2}")
965
+ if resp1.status_code != 200 or resp2.status_code != 200:
966
+ console.print("[red]Could not fetch one or both runs. Check IDs.[/red]")
967
+ sys.exit(1)
968
+
969
+ run1 = resp1.json()
970
+ run2 = resp2.json()
971
+ flat1 = flatten_payload(run1.get("payload", {}))
972
+ flat2 = flatten_payload(run2.get("payload", {}))
973
+
974
+ all_keys = sorted(set(flat1) | set(flat2))
975
+
976
+ if args.json:
977
+ diff_data = []
978
+ for key in all_keys:
979
+ v1 = flat1.get(key, None)
980
+ v2 = flat2.get(key, None)
981
+ delta_str, _ = compute_delta(v1, v2)
982
+ diff_data.append({"path": key, "run1": v1, "run2": v2, "delta": delta_str})
983
+ print(json.dumps(diff_data, indent=2))
984
+ return
985
+
986
+ table = Table(title=f"Run Diff: {args.id1} vs {args.id2}")
987
+ table.add_column("Metric / Parameter", style="cyan")
988
+ table.add_column(f"Run {args.id1}", style="white")
989
+ table.add_column(f"Run {args.id2}", style="white")
990
+ table.add_column("Δ", style="white")
991
+
992
+ for key in all_keys:
993
+ v1 = flat1.get(key, "—")
994
+ v2 = flat2.get(key, "—")
995
+ delta_str, color = compute_delta(v1, v2)
996
+ style_prefix = f"[{color}]" if color != "white" else ""
997
+ style_suffix = "[/]" if style_prefix else ""
998
+ table.add_row(
999
+ f"{style_prefix}{key}{style_suffix}",
1000
+ f"{style_prefix}{v1}{style_suffix}",
1001
+ f"{style_prefix}{v2}{style_suffix}",
1002
+ f"{style_prefix}{delta_str}{style_suffix}"
1003
+ )
1004
+
1005
+ console.print(table)
1006
+
1007
+ # ------------------------------------------------------------
1008
+ # Aggregate
1009
+ # ------------------------------------------------------------
1010
+ def cli_aggregate(args):
1011
+ params = {"agg": args.agg, "metric": args.metric}
1012
+ if args.group_by:
1013
+ params["groupBy"] = args.group_by
1014
+ if args.batch:
1015
+ params["batch"] = args.batch
1016
+
1017
+ resp = requests.get(f"{API_BASE}/aggregate", params=params)
1018
+ if resp.status_code != 200:
1019
+ console.print(f"[red]Aggregate failed: {resp.text}[/red]")
1020
+ sys.exit(1)
1021
+
1022
+ data = resp.json()
1023
+ if args.json:
1024
+ print(json.dumps(data, indent=2))
1025
+ return
1026
+
1027
+ if not data:
1028
+ console.print("[yellow]No results.[/yellow]")
1029
+ return
1030
+
1031
+ table = Table(title=f"{args.agg}({args.metric})" +
1032
+ (f" grouped by {args.group_by}" if args.group_by else ""))
1033
+ if args.group_by:
1034
+ table.add_column(args.group_by, style="cyan")
1035
+ table.add_column(args.agg, style="green", justify="right")
1036
+
1037
+ for row in data:
1038
+ if args.group_by:
1039
+ table.add_row(str(row.get("group", "")), f"{row['result']:.6f}")
1040
+ else:
1041
+ table.add_row(f"{row['result']:.6f}")
1042
+ console.print(table)
1043
+
1044
+ # ------------------------------------------------------------
1045
+ # Main entry point
1046
+ # ------------------------------------------------------------
1047
+ def main():
1048
+ parser = argparse.ArgumentParser(description="RunLedger CLI")
1049
+ subparsers = parser.add_subparsers(dest="command", help="Command")
1050
+
1051
+ # scan
1052
+ scan_parser = subparsers.add_parser("scan", help="Scan a folder of JSON files")
1053
+ scan_parser.add_argument("folder", help="Path to the folder")
1054
+ scan_parser.add_argument("--batch", help="Custom batch name (default: folder name)")
1055
+ scan_parser.add_argument("--jsonl", action="store_true", help="Process files line by line (JSONL format)")
1056
+
1057
+ # stop & status
1058
+ subparsers.add_parser("stop", help="Stop the RunLedger backend")
1059
+ subparsers.add_parser("status", help="Show backend status")
1060
+
1061
+ # search – supports multiple --metric --op --value triples
1062
+ search_parser = subparsers.add_parser("search", help="Search runs")
1063
+ search_parser.add_argument("--metric", action='append', dest='metrics',
1064
+ help="Metric key (can be repeated)")
1065
+ search_parser.add_argument("--op", action='append', dest='ops',
1066
+ choices=["gt","gte","lt","lte","eq"],
1067
+ help="Operator (can be repeated)")
1068
+ search_parser.add_argument("--value", action='append', dest='values',
1069
+ help="Value to compare (can be repeated)")
1070
+ search_parser.add_argument("--combine", choices=["and","or"], default=None,
1071
+ help="Combine multiple conditions with AND or OR")
1072
+ search_parser.add_argument("--q", help="Full‑text / fuzzy phrase")
1073
+ search_parser.add_argument("--fuzzy", action="store_true", help="Enable fuzzy search")
1074
+ search_parser.add_argument("--batch")
1075
+ search_parser.add_argument("--summary", action="store_true", help="Show only ID and source file")
1076
+ search_parser.add_argument("--json", action="store_true", help="Output raw JSON instead of block format")
1077
+ search_parser.add_argument("--block", type=int, help="Show only this block depth (default: all levels)")
1078
+ search_parser.add_argument("--pointers", dest="pointers", action="store_true", default=True,
1079
+ help="Show exact match locations (default: True)")
1080
+ search_parser.add_argument("--no-pointers", dest="pointers", action="store_false",
1081
+ help="Disable match pointers")
1082
+ search_parser.add_argument("--save", help="Save this search under a name")
1083
+ search_parser.add_argument("--saved", help="Run a previously saved search by name")
1084
+
1085
+ # preview
1086
+ preview_parser = subparsers.add_parser("preview", help="Show block levels for a metric match")
1087
+ preview_parser.add_argument("--metric", required=True)
1088
+ preview_parser.add_argument("--op", required=True, choices=["gt","gte","lt","lte","eq"])
1089
+ preview_parser.add_argument("--value", required=True)
1090
+ preview_parser.add_argument("--batch")
1091
+ preview_parser.add_argument("--pointers", dest="pointers", action="store_true", default=True,
1092
+ help="Show exact match locations (default: True)")
1093
+ preview_parser.add_argument("--no-pointers", dest="pointers", action="store_false",
1094
+ help="Disable match pointers")
1095
+
1096
+ # export – supports same multi‑filter as search
1097
+ export_parser = subparsers.add_parser("export", help="Export search results to a file")
1098
+ export_parser.add_argument("--metric", action='append', dest='metrics',
1099
+ help="Metric key (can be repeated)")
1100
+ export_parser.add_argument("--op", action='append', dest='ops',
1101
+ choices=["gt","gte","lt","lte","eq"],
1102
+ help="Operator (can be repeated)")
1103
+ export_parser.add_argument("--value", action='append', dest='values',
1104
+ help="Value to compare (can be repeated)")
1105
+ export_parser.add_argument("--combine", choices=["and","or"], default=None,
1106
+ help="Combine multiple conditions with AND or OR")
1107
+ export_parser.add_argument("--q", help="Full‑text / fuzzy phrase")
1108
+ export_parser.add_argument("--fuzzy", action="store_true", help="Enable fuzzy search")
1109
+ export_parser.add_argument("--batch")
1110
+ export_parser.add_argument("--block", type=int, help="Block depth to export (default: all levels)")
1111
+ export_parser.add_argument("--format", choices=["text","json"], default="text",
1112
+ help="Output format (text or json)")
1113
+ export_parser.add_argument("--output", help="File to write to (default: stdout)")
1114
+ export_parser.add_argument("--pointers", dest="pointers", action="store_true", default=True,
1115
+ help="Show exact match locations (default: True)")
1116
+ export_parser.add_argument("--no-pointers", dest="pointers", action="store_false",
1117
+ help="Disable match pointers")
1118
+
1119
+ # metrics
1120
+ metrics_parser = subparsers.add_parser("metrics", help="List available metric keys")
1121
+ metrics_parser.add_argument("--batch")
1122
+ metrics_parser.add_argument("--verbose", action="store_true", help="Show full dot‑path and depth")
1123
+
1124
+ # interactive
1125
+ interactive_parser = subparsers.add_parser("interactive", help="Launch the interactive search wizard")
1126
+ interactive_parser.add_argument("--batch")
1127
+
1128
+ # saved
1129
+ saved_parser = subparsers.add_parser("saved", help="Manage saved searches")
1130
+ saved_sub = saved_parser.add_subparsers(dest="saved_action")
1131
+ saved_list = saved_sub.add_parser("list", help="List saved searches")
1132
+ saved_list.add_argument("--batch")
1133
+ saved_delete = saved_sub.add_parser("delete", help="Delete a saved search")
1134
+ saved_delete.add_argument("name")
1135
+ saved_delete.add_argument("--batch")
1136
+
1137
+ # diff
1138
+ diff_parser = subparsers.add_parser("diff", help="Compare two runs side‑by‑side")
1139
+ diff_parser.add_argument("id1", type=int, help="Run ID (or first run ID for pairwise diff)")
1140
+ diff_parser.add_argument("id2", type=int, nargs="?", default=None, help="Second run ID (optional if using --history)")
1141
+ diff_parser.add_argument("--json", action="store_true", help="Output raw JSON")
1142
+ diff_parser.add_argument("--history", action="store_true", help="Diff all versions of the same run identity")
1143
+ diff_parser.add_argument("--batch", help="Batch name (required with --history)")
1144
+
1145
+ # aggregate
1146
+ agg_parser = subparsers.add_parser("aggregate", help="Aggregate metrics (AVG/MAX/MIN/SUM/COUNT)")
1147
+ agg_parser.add_argument("--metric", required=True, help="Metric path to aggregate")
1148
+ agg_parser.add_argument("--agg", required=True, choices=["AVG","MAX","MIN","SUM","COUNT"], help="Aggregate function")
1149
+ agg_parser.add_argument("--group-by", help="Field to group results by")
1150
+ agg_parser.add_argument("--batch", help="Batch name")
1151
+ agg_parser.add_argument("--json", action="store_true", help="Output raw JSON")
1152
+
1153
+ # guided
1154
+ subparsers.add_parser("guided", help="Interactive guided experiment search")
1155
+
1156
+ args = parser.parse_args()
1157
+
1158
+ if not check_docker():
1159
+ sys.exit(1)
1160
+
1161
+ if args.command is None:
1162
+ try:
1163
+ resp = requests.get(API_BASE, params={"size": 1})
1164
+ has_data = resp.status_code == 200 and resp.json().get("totalElements", 0) > 0
1165
+ except Exception:
1166
+ has_data = False
1167
+
1168
+ if not has_data:
1169
+ from cli.guided import run_guided_search
1170
+ run_guided_search()
1171
+ else:
1172
+ console.print("[bold cyan]RunLedger[/bold cyan] – zero‑friction experiment search")
1173
+ console.print("\nCommon commands:")
1174
+ console.print(" [bold]runledger scan <folder>[/bold] Ingest experiment files")
1175
+ console.print(" [bold]runledger search --metric <key> --op <op> --value <v>[/bold]")
1176
+ console.print(" [bold]runledger guided[/bold] Interactive search wizard")
1177
+ console.print("\nUse [bold]runledger --help[/bold] for full command list.")
1178
+ return
1179
+
1180
+ if args.command == "scan":
1181
+ if not backend_running():
1182
+ start_backend()
1183
+ else:
1184
+ console.print("Backend already running.")
1185
+ batch = args.batch if args.batch else Path(args.folder).name
1186
+ ingest_folder(args.folder, batch, jsonl=args.jsonl)
1187
+
1188
+ console.print(f"\n[bold]Schema summary for batch '{batch}':[/bold]")
1189
+ verbose_metrics = fetch_verbose_metrics(batch)
1190
+ if not verbose_metrics:
1191
+ console.print("[yellow]No metrics were discovered.[/yellow]")
1192
+ else:
1193
+ table = Table(title="Key -> Resolved Path")
1194
+ table.add_column("Shorthand Key", style="cyan", no_wrap=True)
1195
+ table.add_column("Full Dot-Path", style="green")
1196
+ table.add_column("Depth", justify="right")
1197
+ for item in verbose_metrics:
1198
+ table.add_row(item["key"], item["path"], str(item["depth"]))
1199
+ console.print(table)
1200
+
1201
+ deepest = max(verbose_metrics, key=lambda x: x["depth"])
1202
+ console.print(f"\n[bold]Block preview for deepest metric '{deepest['key']}':[/bold]")
1203
+
1204
+ preview_run = None
1205
+ page = 0
1206
+ while True:
1207
+ resp = requests.get(API_BASE, params={"batch": batch, "size": 10, "page": page})
1208
+ if resp.status_code != 200:
1209
+ break
1210
+ data = resp.json()
1211
+ if not data.get("content"):
1212
+ break
1213
+ for run in data["content"]:
1214
+ if navigate_json_pointer(run.get("payload", {}), "/" + deepest["path"].replace(".", "/").replace("[]","")):
1215
+ preview_run = run
1216
+ break
1217
+ if preview_run:
1218
+ break
1219
+ page += 1
1220
+ if page >= 10:
1221
+ break
1222
+
1223
+ if preview_run:
1224
+ show_blocks(preview_run, deepest["path"])
1225
+ else:
1226
+ console.print("[yellow]No run in the batch contains this metric – preview skipped.[/yellow]")
1227
+
1228
+ console.print("\n[dim]Tip: run `ledger preview --metric <key> --op <gt|lt|eq> --value <v> --batch <batch>` to explore block levels for any metric.[/dim]")
1229
+
1230
+ elif args.command == "stop":
1231
+ stop_backend()
1232
+ elif args.command == "status":
1233
+ show_status()
1234
+ elif args.command == "search":
1235
+ cli_search(args)
1236
+ elif args.command == "metrics":
1237
+ cli_metrics(args)
1238
+ elif args.command == "preview":
1239
+ cli_preview(args)
1240
+ elif args.command == "export":
1241
+ cli_export(args)
1242
+ elif args.command == "interactive":
1243
+ if not backend_running():
1244
+ start_backend()
1245
+ interactive_search(args.batch)
1246
+ elif args.command == "saved":
1247
+ if args.saved_action == "list":
1248
+ searches = list_saved_searches(args.batch)
1249
+ if not searches:
1250
+ console.print("No saved searches.")
1251
+ else:
1252
+ table = Table(title="Saved Searches")
1253
+ table.add_column("Name")
1254
+ table.add_column("Batch")
1255
+ table.add_column("Created")
1256
+ for s in searches:
1257
+ table.add_row(s["name"], s.get("batch", ""), s["createdAt"])
1258
+ console.print(table)
1259
+ elif args.saved_action == "delete":
1260
+ ok = delete_saved_search(args.name, args.batch)
1261
+ if ok:
1262
+ console.print(f"[green]Deleted saved search '{args.name}'.[/green]")
1263
+ else:
1264
+ console.print(f"[red]Saved search '{args.name}' not found.[/red]")
1265
+ elif args.command == "diff":
1266
+ cli_diff(args)
1267
+ elif args.command == "aggregate":
1268
+ cli_aggregate(args)
1269
+ elif args.command == "guided":
1270
+ from cli.guided import run_guided_search
1271
+ run_guided_search()
1272
+ else:
1273
+ parser.print_help()
1274
+
1275
+ if __name__ == "__main__":
1276
+ main()